From cb0b84a2d3968057647604404231c2ac66f4baaa Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Sun, 12 Apr 2026 15:04:10 +0100 Subject: [PATCH] feat: show build logs in chat for start_workspace and create_workspace tools (#24194) --- coderd/x/chatd/chatd.go | 23 +- coderd/x/chatd/chattool/chattool.go | 48 +- coderd/x/chatd/chattool/createworkspace.go | 186 +++++--- .../x/chatd/chattool/createworkspace_test.go | 426 +++++++++++++++++- coderd/x/chatd/chattool/startworkspace.go | 112 ++++- .../x/chatd/chattool/startworkspace_test.go | 303 ++++++++++++- site/src/api/queries/workspaceBuilds.ts | 26 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 1 + .../pages/AgentsPage/AgentChatPageView.tsx | 353 ++++++++------- site/src/pages/AgentsPage/AgentsPage.tsx | 8 +- .../tools/CreateWorkspaceTool.tsx | 102 +++-- .../ChatElements/tools/StartWorkspaceTool.tsx | 73 +++ .../ChatElements/tools/Tool.stories.tsx | 219 +++++++++ .../components/ChatElements/tools/Tool.tsx | 32 +- .../WorkspaceBuildLogSection.stories.tsx | 171 +++++++ .../tools/WorkspaceBuildLogSection.tsx | 172 +++++++ .../context/ChatWorkspaceContext.tsx | 25 + 17 files changed, 1941 insertions(+), 339 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx create mode 100644 site/src/pages/AgentsPage/context/ChatWorkspaceContext.tsx diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 6a77fdee8d..19efbbc7a4 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4998,6 +4998,13 @@ func (p *Server) runChat( // focus on completing their delegated task. if !chat.ParentChatID.Valid { // Workspace provisioning tools. + onChatUpdated := func(updatedChat database.Chat) { + workspaceCtx.selectWorkspace(updatedChat) + // Notify the frontend immediately so it can + // start streaming build logs before the tool + // completes. + p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil) + } tools = append(tools, chattool.ListTemplates(chattool.ListTemplatesOptions{ DB: p.db, @@ -5017,17 +5024,19 @@ func (p *Server) runChat( AgentConnFn: chattool.AgentConnFunc(p.agentConnFn), AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout, WorkspaceMu: &workspaceMu, - OnChatUpdated: workspaceCtx.selectWorkspace, + OnChatUpdated: onChatUpdated, Logger: p.logger, AllowedTemplateIDs: p.chatTemplateAllowlist, }), chattool.StartWorkspace(chattool.StartWorkspaceOptions{ - DB: p.db, - OwnerID: chat.OwnerID, - ChatID: chat.ID, - StartFn: p.startWorkspaceFn, - AgentConnFn: chattool.AgentConnFunc(p.agentConnFn), - WorkspaceMu: &workspaceMu, + DB: p.db, + OwnerID: chat.OwnerID, + ChatID: chat.ID, + StartFn: p.startWorkspaceFn, + AgentConnFn: chattool.AgentConnFunc(p.agentConnFn), + WorkspaceMu: &workspaceMu, + OnChatUpdated: onChatUpdated, + Logger: p.logger, }), ) // Plan presentation tool. diff --git a/coderd/x/chatd/chattool/chattool.go b/coderd/x/chatd/chattool/chattool.go index 05f5366845..5a6a0b9872 100644 --- a/coderd/x/chatd/chattool/chattool.go +++ b/coderd/x/chatd/chattool/chattool.go @@ -9,7 +9,8 @@ import ( ) // toolResponse builds a fantasy.ToolResponse from a JSON-serializable -// result payload. +// result map. The map constraint ensures all tool results serialize +// to JSON objects so the frontend can safely parse them. func toolResponse(result map[string]any) fantasy.ToolResponse { data, err := json.Marshal(result) if err != nil { @@ -18,6 +19,17 @@ func toolResponse(result map[string]any) fantasy.ToolResponse { return fantasy.NewTextResponse(string(data)) } +// buildToolResponse marshals a buildErrorResult into a tool response. +// Separate from toolResponse to keep the map[string]any constraint +// on the general helper while allowing typed error structs. +func buildToolResponse(r buildErrorResult) fantasy.ToolResponse { + data, err := json.Marshal(r) + if err != nil { + return fantasy.NewTextResponse("{}") + } + return fantasy.NewTextResponse(string(data)) +} + func truncateRunes(value string, maxLen int) string { if maxLen <= 0 || value == "" { return "" @@ -33,6 +45,40 @@ func truncateRunes(value string, maxLen int) string { return string(runes[:maxLen]) } +// buildErrorResult is a structured error response that preserves +// the build ID alongside the error message. This lets the frontend +// keep showing build logs when a build fails instead of losing +// them on the error transition. +type buildErrorResult struct { + Error string `json:"error"` + BuildID string `json:"build_id,omitempty"` +} + +func newBuildError(msg string, buildID uuid.UUID) buildErrorResult { + r := buildErrorResult{Error: msg} + if buildID != uuid.Nil { + r.BuildID = buildID.String() + } + return r +} + +// setBuildID adds the build_id field to a tool response map when +// the build ID is known (non-zero). +func setBuildID(result map[string]any, buildID uuid.UUID) { + if buildID != uuid.Nil { + result["build_id"] = buildID.String() + } +} + +// setNoBuild marks the response with no_build: true when no build +// was triggered. The frontend uses this flag to suppress the +// build-log section for already-running workspaces. +func setNoBuild(result map[string]any, buildID uuid.UUID) { + if buildID == uuid.Nil { + result["no_build"] = true + } +} + // isTemplateAllowed checks whether a template ID is permitted by the // configured allowlist. A nil function or an empty allowlist means // all templates are allowed. diff --git a/coderd/x/chatd/chattool/createworkspace.go b/coderd/x/chatd/chattool/createworkspace.go index e6b61cad2b..72d4241c1d 100644 --- a/coderd/x/chatd/chattool/createworkspace.go +++ b/coderd/x/chatd/chattool/createworkspace.go @@ -121,12 +121,15 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool { } // Check for an existing workspace on the chat. - existing, done, existErr := options.checkExistingWorkspace(ctx) - if existErr != nil { - return fantasy.NewTextErrorResponse(existErr.Error()), nil + check := options.checkExistingWorkspace(ctx) + if check.Err != nil { + if check.FailedBuildID != uuid.Nil { + return buildToolResponse(newBuildError(check.Err.Error(), check.FailedBuildID)), nil + } + return fantasy.NewTextErrorResponse(check.Err.Error()), nil } - if done { - return toolResponse(existing), nil + if check.Done { + return toolResponse(check.Result), nil } ownerID := options.OwnerID @@ -193,21 +196,57 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool { return fantasy.NewTextErrorResponse(err.Error()), nil } - // Wait for the build to complete and the agent to - // come online so subsequent tools can use the - // workspace immediately. - if options.DB != nil { - if err := waitForBuild(ctx, options.DB, workspace.ID); err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("workspace build failed: %w", err).Error(), - ), nil + // Persist the workspace binding on the chat + // immediately so the frontend can start streaming + // build logs while the build is still running. + // Note: this binding is intentional even if the build + // later fails. The checkExistingWorkspace recovery + // path handles failed workspaces by allowing + // re-creation. + if options.DB != nil && options.ChatID != uuid.Nil { + updatedChat, err := options.DB.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: options.ChatID, + WorkspaceID: uuid.NullUUID{ + UUID: workspace.ID, + Valid: true, + }, + BuildID: uuid.NullUUID{ + UUID: workspace.LatestBuild.ID, + Valid: workspace.LatestBuild.ID != uuid.Nil, + }, + // AgentID is left null because the build hasn't + // completed yet. The chatd runtime binds it once + // the agent comes online. + AgentID: uuid.NullUUID{}, + }) + if err != nil { + options.Logger.Error(ctx, "failed to persist chat workspace association", + slog.F("chat_id", options.ChatID), + slog.F("workspace_id", workspace.ID), + slog.Error(err), + ) + } else if options.OnChatUpdated != nil { + options.OnChatUpdated(updatedChat) } } + // Wait for the build to complete and the agent to + // come online so subsequent tools can use the + // workspace immediately. + buildID := workspace.LatestBuild.ID + if options.DB != nil && buildID != uuid.Nil { + if err := waitForBuild(ctx, options.DB, buildID); err != nil { + return buildToolResponse(newBuildError( + xerrors.Errorf("workspace build failed: %w", err).Error(), + buildID, + )), nil + } + } result := map[string]any{ "created": true, "workspace_name": workspace.FullName(), } + setBuildID(result, buildID) // Select the chat agent so follow-up tools wait on the // intended workspace agent. @@ -229,32 +268,6 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool { } } - // Persist the workspace binding on the chat. - if options.DB != nil && options.ChatID != uuid.Nil { - updatedChat, err := options.DB.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ - ID: options.ChatID, - WorkspaceID: uuid.NullUUID{ - UUID: workspace.ID, - Valid: true, - }, - // BuildID and AgentID are intentionally left null - // here. The chatd runtime (loadWorkspaceAgentLocked) - // will bind them on the next turn. Authoritative - // tool-path binding is deferred to a follow-up PR. - BuildID: uuid.NullUUID{}, - AgentID: uuid.NullUUID{}, - }) - if err != nil { - options.Logger.Error(ctx, "failed to persist chat workspace association", - slog.F("chat_id", options.ChatID), - slog.F("workspace_id", workspace.ID), - slog.Error(err), - ) - } else if options.OnChatUpdated != nil { - options.OnChatUpdated(updatedChat) - } - } - // Wait for the agent to come online and startup scripts to finish. if workspaceAgentID != uuid.Nil { agentStatus := waitForAgentReady(ctx, options.DB, workspaceAgentID, options.AgentConnFn) @@ -267,16 +280,31 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool { }) } -// checkExistingWorkspace checks whether the configured chat already has -// a usable workspace. Returns the result map and true if the caller -// should return early (workspace exists and is alive or building). -// Returns false if the caller should proceed with creation (workspace -// is dead or missing). +// existingWorkspaceResult holds the outcome of checking for an +// existing workspace on the chat. +type existingWorkspaceResult struct { + // Result is the tool response map when Done is true. + Result map[string]any + // Done indicates the caller should return early. + Done bool + // FailedBuildID is set when waitForBuild failed, so the + // caller can include it in a structured error response. + FailedBuildID uuid.UUID + // Err is non-nil when the check itself failed. + Err error +} + +// checkExistingWorkspace checks whether the configured chat +// already has a usable workspace. Returns an +// existingWorkspaceResult with Done set when the caller should +// return early (workspace exists and is alive or building). +// Returns Done unset if the caller should proceed with creation +// (workspace is dead or missing). func (o CreateWorkspaceOptions) checkExistingWorkspace( ctx context.Context, -) (map[string]any, bool, error) { +) existingWorkspaceResult { if o.DB == nil || o.ChatID == uuid.Nil { - return nil, false, nil + return existingWorkspaceResult{} } db := o.DB @@ -286,42 +314,61 @@ func (o CreateWorkspaceOptions) checkExistingWorkspace( chat, err := db.GetChatByID(ctx, chatID) if err != nil { - return nil, false, xerrors.Errorf("load chat: %w", err) + return existingWorkspaceResult{Err: xerrors.Errorf("load chat: %w", err)} } if !chat.WorkspaceID.Valid { - return nil, false, nil + return existingWorkspaceResult{} } ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID) if err != nil { - return nil, false, xerrors.Errorf("load workspace: %w", err) + return existingWorkspaceResult{Err: xerrors.Errorf("load workspace: %w", err)} } // Workspace was soft-deleted — allow creation. if ws.Deleted { - return nil, false, nil + return existingWorkspaceResult{} } // Check the latest build status. build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, ws.ID) if err != nil { // Can't determine status — allow creation. - return nil, false, nil + return existingWorkspaceResult{} } job, err := db.GetProvisionerJobByID(ctx, build.JobID) if err != nil { - return nil, false, nil + return existingWorkspaceResult{} } switch job.JobStatus { case database.ProvisionerJobStatusPending, database.ProvisionerJobStatusRunning: - // Build is in progress — wait for it instead of - // creating a new workspace. - if err := waitForBuild(ctx, db, ws.ID); err != nil { - return nil, false, xerrors.Errorf( - "existing workspace build failed: %w", err, + // Build is in progress. Publish the build ID so the + // frontend can start streaming logs, then wait. + updatedChat, bindErr := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: o.ChatID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + BuildID: uuid.NullUUID{ + UUID: build.ID, + Valid: build.ID != uuid.Nil, + }, + AgentID: uuid.NullUUID{}, + }) + if bindErr != nil { + o.Logger.Error(ctx, "failed to persist build ID on chat binding", + slog.F("chat_id", o.ChatID), + slog.F("build_id", build.ID), + slog.Error(bindErr), ) + } else if o.OnChatUpdated != nil { + o.OnChatUpdated(updatedChat) + } + if err := waitForBuild(ctx, db, build.ID); err != nil { + return existingWorkspaceResult{ + FailedBuildID: build.ID, + Err: xerrors.Errorf("existing workspace build failed: %w", err), + } } result := map[string]any{ "created": false, @@ -329,6 +376,7 @@ func (o CreateWorkspaceOptions) checkExistingWorkspace( "status": "already_exists", "message": "workspace build completed", } + setBuildID(result, build.ID) agents, agentsErr := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, ws.ID) if agentsErr == nil && len(agents) > 0 { selected, selectErr := agentselect.FindChatAgent(agents) @@ -343,18 +391,18 @@ func (o CreateWorkspaceOptions) checkExistingWorkspace( result[k] = v } } - return result, true, nil + return existingWorkspaceResult{Result: result, Done: true} case database.ProvisionerJobStatusSucceeded: // If the workspace was stopped, tell the model to use // start_workspace instead of creating a new one. if build.Transition == database.WorkspaceTransitionStop { - return map[string]any{ + return existingWorkspaceResult{Result: map[string]any{ "created": false, "workspace_name": ws.Name, "status": "stopped", "message": "workspace is stopped; use start_workspace to start it", - }, true, nil + }, Done: true} } // Build succeeded — use the agent's recent DB-backed @@ -383,13 +431,13 @@ func (o CreateWorkspaceOptions) checkExistingWorkspace( for k, v := range waitForAgentReady(ctx, db, selected.ID, nil) { result[k] = v } - return result, true, nil + return existingWorkspaceResult{Result: result, Done: true} case database.WorkspaceAgentStatusConnecting: result["message"] = "workspace exists and the agent is still connecting" for k, v := range waitForAgentReady(ctx, db, selected.ID, agentConnFn) { result[k] = v } - return result, true, nil + return existingWorkspaceResult{Result: result, Done: true} case database.WorkspaceAgentStatusDisconnected, database.WorkspaceAgentStatusTimeout: // Agent is offline or never became ready - allow @@ -397,20 +445,20 @@ func (o CreateWorkspaceOptions) checkExistingWorkspace( } } // No agent ID or no agent status — allow creation. - return nil, false, nil + return existingWorkspaceResult{} default: // Failed, canceled, etc — allow creation. - return nil, false, nil + return existingWorkspaceResult{} } } -// waitForBuild polls the workspace's latest build until it +// waitForBuild polls the specified build until its provisioner job // completes or the context expires. func waitForBuild( ctx context.Context, db database.Store, - workspaceID uuid.UUID, + buildID uuid.UUID, ) error { buildCtx, cancel := context.WithTimeout(ctx, buildTimeout) defer cancel() @@ -419,11 +467,9 @@ func waitForBuild( defer ticker.Stop() for { - build, err := db.GetLatestWorkspaceBuildByWorkspaceID( - buildCtx, workspaceID, - ) + build, err := db.GetWorkspaceBuildByID(buildCtx, buildID) if err != nil { - return xerrors.Errorf("get latest build: %w", err) + return xerrors.Errorf("get build: %w", err) } job, err := db.GetProvisionerJobByID(buildCtx, build.JobID) diff --git a/coderd/x/chatd/chattool/createworkspace_test.go b/coderd/x/chatd/chattool/createworkspace_test.go index 060f9c4b7e..09fab451cd 100644 --- a/coderd/x/chatd/chattool/createworkspace_test.go +++ b/coderd/x/chatd/chattool/createworkspace_test.go @@ -129,6 +129,7 @@ func TestCreateWorkspace_PrefersChatSuffixAgent(t *testing.T) { templateID := uuid.New() workspaceID := uuid.New() jobID := uuid.New() + buildID := uuid.New() fallbackAgentID := uuid.New() chatAgentID := uuid.New() @@ -146,8 +147,9 @@ func TestCreateWorkspace_PrefersChatSuffixAgent(t *testing.T) { Return("0s", nil) db.EXPECT(). - GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID). + GetWorkspaceBuildByID(gomock.Any(), buildID). Return(database.WorkspaceBuild{ + ID: buildID, WorkspaceID: workspaceID, JobID: jobID, }, nil) @@ -175,6 +177,9 @@ func TestCreateWorkspace_PrefersChatSuffixAgent(t *testing.T) { ID: workspaceID, Name: req.Name, OwnerName: "testuser", + LatestBuild: codersdk.WorkspaceBuild{ + ID: buildID, + }, }, nil } agentConnFn := func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { @@ -200,6 +205,10 @@ func TestCreateWorkspace_PrefersChatSuffixAgent(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, resp.Content) require.Equal(t, chatAgentID, connectedAgentID) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, buildID.String(), result["build_id"]) } func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { @@ -213,6 +222,7 @@ func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { templateID := uuid.New() workspaceID := uuid.New() jobID := uuid.New() + buildID := uuid.New() db.EXPECT(). GetChatByID(gomock.Any(), chatID). @@ -229,8 +239,9 @@ func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { GetChatWorkspaceTTL(gomock.Any()). Return("0s", nil) db.EXPECT(). - GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID). + GetWorkspaceBuildByID(gomock.Any(), buildID). Return(database.WorkspaceBuild{ + ID: buildID, WorkspaceID: workspaceID, JobID: jobID, }, nil) @@ -244,7 +255,7 @@ func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { UpdateChatWorkspaceBinding(gomock.Any(), database.UpdateChatWorkspaceBindingParams{ ID: chatID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, - BuildID: uuid.NullUUID{}, + BuildID: uuid.NullUUID{UUID: buildID, Valid: true}, AgentID: uuid.NullUUID{}, }). Return(database.Chat{ @@ -267,6 +278,9 @@ func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { ID: workspaceID, Name: req.Name, OwnerName: "testuser", + LatestBuild: codersdk.WorkspaceBuild{ + ID: buildID, + }, }, nil }, AgentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { @@ -291,6 +305,86 @@ func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { require.Equal(t, "testuser/test-selection-error", result["workspace_name"]) require.Equal(t, "selection_error", result["agent_status"]) require.Contains(t, result["agent_error"], "multiple agents match the chat suffix") + require.Equal(t, buildID.String(), result["build_id"]) +} + +func TestCreateWorkspace_PostCreationBuildFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + ownerID := uuid.New() + templateID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + buildID := uuid.New() + + db.EXPECT(). + GetAuthorizationUserRoles(gomock.Any(), ownerID). + Return(database.GetAuthorizationUserRolesRow{ + ID: ownerID, + Roles: []string{}, + Groups: []string{}, + Status: database.UserStatusActive, + }, nil) + + db.EXPECT(). + GetChatWorkspaceTTL(gomock.Any()). + Return("0s", nil) + + // waitForBuild fetches the build by ID. + db.EXPECT(). + GetWorkspaceBuildByID(gomock.Any(), buildID). + Return(database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspaceID, + JobID: jobID, + }, nil) + + // waitForBuild polls the provisioner job. Return Failed. + db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusFailed, + Error: sql.NullString{String: "terraform apply failed", Valid: true}, + }, nil) + + createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + return codersdk.Workspace{ + ID: workspaceID, + Name: req.Name, + OwnerName: "testuser", + LatestBuild: codersdk.WorkspaceBuild{ + ID: buildID, + }, + }, nil + } + + tool := CreateWorkspace(CreateWorkspaceOptions{ + DB: db, + OwnerID: ownerID, + ChatID: uuid.Nil, + CreateFn: createFn, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf(`{"template_id":%q,"name":"test-build-fail"}`, templateID.String()) + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Contains(t, result["error"], "workspace build failed") + require.Equal(t, buildID.String(), result["build_id"]) + require.False(t, resp.IsError, + "buildToolResponse must not set IsError; chatprompt strips structured fields from error responses") } func TestCreateWorkspace_GlobalTTL(t *testing.T) { @@ -335,6 +429,7 @@ func TestCreateWorkspace_GlobalTTL(t *testing.T) { templateID := uuid.New() workspaceID := uuid.New() jobID := uuid.New() + buildID := uuid.New() db.EXPECT(). GetAuthorizationUserRoles(gomock.Any(), ownerID). @@ -350,8 +445,9 @@ func TestCreateWorkspace_GlobalTTL(t *testing.T) { Return(tc.ttlReturn, tc.ttlErr) db.EXPECT(). - GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID). + GetWorkspaceBuildByID(gomock.Any(), buildID). Return(database.WorkspaceBuild{ + ID: buildID, WorkspaceID: workspaceID, JobID: jobID, }, nil) @@ -373,6 +469,9 @@ func TestCreateWorkspace_GlobalTTL(t *testing.T) { ID: workspaceID, Name: req.Name, OwnerName: "testuser", + LatestBuild: codersdk.WorkspaceBuild{ + ID: buildID, + }, }, nil } @@ -394,6 +493,10 @@ func TestCreateWorkspace_GlobalTTL(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, resp.Content) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, buildID.String(), result["build_id"]) + if tc.wantTTLMs != nil { require.NotNil(t, capturedReq.TTLMillis) require.Equal(t, *tc.wantTTLMs, *capturedReq.TTLMillis) @@ -445,12 +548,184 @@ func TestCheckExistingWorkspace_ConnectedAgent(t *testing.T) { } options := testCheckExistingWorkspaceOptions(db, chatID, connFn) - result, done, err := options.checkExistingWorkspace(context.Background()) - require.NoError(t, err) - require.True(t, done) - require.Equal(t, "already_exists", result["status"]) - require.Equal(t, "existing-workspace", result["workspace_name"]) - require.Equal(t, "workspace is already running and recently connected", result["message"]) + check := options.checkExistingWorkspace(context.Background()) + require.NoError(t, check.Err) + require.True(t, check.Done) + require.Equal(t, "already_exists", check.Result["status"]) + require.Equal(t, "existing-workspace", check.Result["workspace_name"]) + require.Equal(t, "workspace is already running and recently connected", check.Result["message"]) +} + +func TestCheckExistingWorkspace_InProgressBuildReturnsBuildID(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + chatID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + buildID := uuid.New() + + // GetChatByID returns a chat linked to a workspace. + db.EXPECT(). + GetChatByID(gomock.Any(), chatID). + Return(database.Chat{ + ID: chatID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + // GetWorkspaceByID returns a non-deleted workspace. + db.EXPECT(). + GetWorkspaceByID(gomock.Any(), workspaceID). + Return(database.Workspace{ + ID: workspaceID, + Name: "building-workspace", + }, nil) + + // GetLatestWorkspaceBuildByWorkspaceID is called once in + // checkExistingWorkspace. waitForBuild now uses + // GetWorkspaceBuildByID to track the specific build. + db.EXPECT(). + GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID). + Return(database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspaceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + }, nil) + db.EXPECT(). + GetWorkspaceBuildByID(gomock.Any(), buildID). + Return(database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspaceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + }, nil) + + // First GetProvisionerJobByID (in checkExistingWorkspace) returns + // Running, triggering waitForBuild. The second call (waitForBuild's + // first poll) returns Succeeded so the loop exits immediately. + firstJob := db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusRunning, + }, nil) + db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusSucceeded, + }, nil). + After(firstJob) + + // The in-progress path now publishes the build ID before + // waitForBuild. + db.EXPECT(). + UpdateChatWorkspaceBinding(gomock.Any(), database.UpdateChatWorkspaceBindingParams{ + ID: chatID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + BuildID: uuid.NullUUID{UUID: buildID, Valid: true}, + AgentID: uuid.NullUUID{}, + }). + Return(database.Chat{ + ID: chatID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + // After waitForBuild completes, checkExistingWorkspace fetches + // agents. Return empty to keep the test focused on build_id. + db.EXPECT(). + GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). + Return([]database.WorkspaceAgent{}, nil) + + options := testCheckExistingWorkspaceOptions(db, chatID, nil) + check := options.checkExistingWorkspace(context.Background()) + require.NoError(t, check.Err) + require.True(t, check.Done) + require.Equal(t, false, check.Result["created"]) + require.Equal(t, "already_exists", check.Result["status"]) + require.Equal(t, buildID.String(), check.Result["build_id"]) + require.Equal(t, "building-workspace", check.Result["workspace_name"]) + require.Equal(t, "workspace build completed", check.Result["message"]) +} + +func TestCheckExistingWorkspace_InProgressBuildFailureReturnsBuildID(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + chatID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + buildID := uuid.New() + + db.EXPECT(). + GetChatByID(gomock.Any(), chatID). + Return(database.Chat{ + ID: chatID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + db.EXPECT(). + GetWorkspaceByID(gomock.Any(), workspaceID). + Return(database.Workspace{ + ID: workspaceID, + Name: "failing-workspace", + }, nil) + + db.EXPECT(). + GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID). + Return(database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspaceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + }, nil) + db.EXPECT(). + GetWorkspaceBuildByID(gomock.Any(), buildID). + Return(database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspaceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + }, nil) + + // First call returns Running (triggers waitForBuild), second + // returns Failed so waitForBuild returns an error. + firstJob := db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusRunning, + }, nil) + db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusFailed, + }, nil). + After(firstJob) + + // The in-progress path publishes the build ID before + // waitForBuild. + db.EXPECT(). + UpdateChatWorkspaceBinding(gomock.Any(), database.UpdateChatWorkspaceBindingParams{ + ID: chatID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + BuildID: uuid.NullUUID{UUID: buildID, Valid: true}, + AgentID: uuid.NullUUID{}, + }). + Return(database.Chat{ + ID: chatID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }, nil) + + options := testCheckExistingWorkspaceOptions(db, chatID, nil) + check := options.checkExistingWorkspace(context.Background()) + require.Error(t, check.Err) + require.Contains(t, check.Err.Error(), "existing workspace build failed") + require.Equal(t, buildID, check.FailedBuildID) } func TestCheckExistingWorkspace_ConnectingAgentWaits(t *testing.T) { @@ -494,12 +769,12 @@ func TestCheckExistingWorkspace_ConnectingAgentWaits(t *testing.T) { } options := testCheckExistingWorkspaceOptions(db, chatID, connFn) - result, done, err := options.checkExistingWorkspace(context.Background()) - require.NoError(t, err) - require.True(t, done) + check := options.checkExistingWorkspace(context.Background()) + require.NoError(t, check.Err) + require.True(t, check.Done) require.Equal(t, 1, connectCalls) - require.Equal(t, "already_exists", result["status"]) - require.Equal(t, "workspace exists and the agent is still connecting", result["message"]) + require.Equal(t, "already_exists", check.Result["status"]) + require.Equal(t, "workspace exists and the agent is still connecting", check.Result["message"]) } func TestCheckExistingWorkspace_DeadAgentAllowsCreation(t *testing.T) { @@ -554,14 +829,119 @@ func TestCheckExistingWorkspace_DeadAgentAllowsCreation(t *testing.T) { Return([]database.WorkspaceAgent{tc.agent}, nil) options := testCheckExistingWorkspaceOptions(db, chatID, nil) - result, done, err := options.checkExistingWorkspace(context.Background()) - require.NoError(t, err) - require.False(t, done) - require.Nil(t, result) + check := options.checkExistingWorkspace(context.Background()) + require.NoError(t, check.Err) + require.False(t, check.Done) + require.Nil(t, check.Result) }) } } +func TestWaitForBuild_CanceledJob(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + ownerID := uuid.New() + templateID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + buildID := uuid.New() + + db.EXPECT(). + GetAuthorizationUserRoles(gomock.Any(), ownerID). + Return(database.GetAuthorizationUserRolesRow{ + ID: ownerID, + Roles: []string{}, + Groups: []string{}, + Status: database.UserStatusActive, + }, nil) + + db.EXPECT(). + GetChatWorkspaceTTL(gomock.Any()). + Return("0s", nil) + + // waitForBuild fetches the build by ID. + db.EXPECT(). + GetWorkspaceBuildByID(gomock.Any(), buildID). + Return(database.WorkspaceBuild{ + ID: buildID, + WorkspaceID: workspaceID, + JobID: jobID, + }, nil) + + // waitForBuild polls the provisioner job. Return Canceled. + db.EXPECT(). + GetProvisionerJobByID(gomock.Any(), jobID). + Return(database.ProvisionerJob{ + ID: jobID, + JobStatus: database.ProvisionerJobStatusCanceled, + }, nil) + + createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + return codersdk.Workspace{ + ID: workspaceID, + Name: req.Name, + OwnerName: "testuser", + LatestBuild: codersdk.WorkspaceBuild{ + ID: buildID, + }, + }, nil + } + + tool := CreateWorkspace(CreateWorkspaceOptions{ + DB: db, + OwnerID: ownerID, + ChatID: uuid.Nil, + CreateFn: createFn, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf(`{"template_id":%q,"name":"test-build-cancel"}`, templateID.String()) + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Contains(t, result["error"], "build was canceled") + require.Equal(t, buildID.String(), result["build_id"]) + require.False(t, resp.IsError, + "buildToolResponse must not set IsError; chatprompt strips structured fields from error responses") +} + +func TestCheckExistingWorkspace_StoppedWorkspace(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + chatID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + + expectExistingWorkspaceLookup( + db, + chatID, + workspaceID, + jobID, + "stopped-workspace", + database.ProvisionerJobStatusSucceeded, + database.WorkspaceTransitionStop, + ) + + options := testCheckExistingWorkspaceOptions(db, chatID, nil) + check := options.checkExistingWorkspace(context.Background()) + require.True(t, check.Done) + require.NoError(t, check.Err) + require.Equal(t, "stopped", check.Result["status"]) + require.Contains(t, check.Result["message"], "start_workspace") +} + func TestCheckExistingWorkspace_DeletedWorkspace(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -587,10 +967,10 @@ func TestCheckExistingWorkspace_DeletedWorkspace(t *testing.T) { }, nil) options := testCheckExistingWorkspaceOptions(db, chatID, nil) - result, done, err := options.checkExistingWorkspace(context.Background()) - require.NoError(t, err) - require.False(t, done, "should allow creation for deleted workspace") - require.Nil(t, result) + check := options.checkExistingWorkspace(context.Background()) + require.NoError(t, check.Err) + require.False(t, check.Done, "should allow creation for deleted workspace") + require.Nil(t, check.Result) } func testCheckExistingWorkspaceOptions( diff --git a/coderd/x/chatd/chattool/startworkspace.go b/coderd/x/chatd/chattool/startworkspace.go index bd6473d183..e9291d2599 100644 --- a/coderd/x/chatd/chattool/startworkspace.go +++ b/coderd/x/chatd/chattool/startworkspace.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/internal/agentselect" "github.com/coder/coder/v2/codersdk" @@ -24,12 +25,14 @@ type StartWorkspaceFn func( // StartWorkspaceOptions configures the start_workspace tool. type StartWorkspaceOptions struct { - DB database.Store - OwnerID uuid.UUID - ChatID uuid.UUID - StartFn StartWorkspaceFn - AgentConnFn AgentConnFunc - WorkspaceMu *sync.Mutex + DB database.Store + OwnerID uuid.UUID + ChatID uuid.UUID + StartFn StartWorkspaceFn + AgentConnFn AgentConnFunc + WorkspaceMu *sync.Mutex + OnChatUpdated func(database.Chat) + Logger slog.Logger } // StartWorkspace returns a tool that starts a stopped workspace @@ -99,18 +102,44 @@ func StartWorkspace(options StartWorkspaceOptions) fantasy.AgentTool { switch job.JobStatus { case database.ProvisionerJobStatusPending, database.ProvisionerJobStatusRunning: - if err := waitForBuild(ctx, options.DB, ws.ID); err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("waiting for in-progress build: %w", err).Error(), - ), nil + // Publish the build ID to the frontend so it + // can start streaming logs immediately. + updatedChat, bindErr := options.DB.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: options.ChatID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + BuildID: uuid.NullUUID{ + UUID: build.ID, + Valid: build.ID != uuid.Nil, + }, + AgentID: uuid.NullUUID{}, + }) + if bindErr != nil { + options.Logger.Error(ctx, "failed to persist build ID on chat binding", + slog.F("chat_id", options.ChatID), + slog.F("build_id", build.ID), + slog.Error(bindErr), + ) + } else if options.OnChatUpdated != nil { + options.OnChatUpdated(updatedChat) } - return waitForAgentAndRespond(ctx, options.DB, options.AgentConnFn, ws) - + if err := waitForBuild(ctx, options.DB, build.ID); err != nil { + // newBuildError returns via toolResponse (IsError: false) + // rather than NewTextErrorResponse (IsError: true) so the + // JSON result preserves build_id for the frontend's log + // viewer. The fantasy/chatprompt pipeline discards structured + // fields from IsError content. + // The frontend detects errors via the "error" key instead. + return buildToolResponse(newBuildError( + xerrors.Errorf("waiting for in-progress build: %w", err).Error(), + build.ID, + )), nil + } + return waitForAgentAndRespond(ctx, options.DB, options.AgentConnFn, ws, build.ID) case database.ProvisionerJobStatusSucceeded: // If the latest successful build is a start // transition, the workspace should be running. if build.Transition == database.WorkspaceTransitionStart { - return waitForAgentAndRespond(ctx, options.DB, options.AgentConnFn, ws) + return waitForAgentAndRespond(ctx, options.DB, options.AgentConnFn, ws, uuid.Nil) } // Otherwise it is stopped (or deleted) — proceed // to start it below. @@ -125,7 +154,7 @@ func StartWorkspace(options StartWorkspaceOptions) fantasy.AgentTool { return fantasy.NewTextErrorResponse(ownerErr.Error()), nil } - _, err = options.StartFn(ownerCtx, options.OwnerID, ws.ID, codersdk.CreateWorkspaceBuildRequest{ + startBuild, err := options.StartFn(ownerCtx, options.OwnerID, ws.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, }) if err != nil { @@ -134,51 +163,84 @@ func StartWorkspace(options StartWorkspaceOptions) fantasy.AgentTool { ), nil } - if err := waitForBuild(ctx, options.DB, ws.ID); err != nil { - return fantasy.NewTextErrorResponse( + // Persist the build ID on the chat binding so the + // frontend can stream logs without polling. + updatedChat, bindErr := options.DB.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: options.ChatID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + BuildID: uuid.NullUUID{ + UUID: startBuild.ID, + Valid: startBuild.ID != uuid.Nil, + }, + AgentID: uuid.NullUUID{}, + }) + if bindErr != nil { + options.Logger.Error(ctx, "failed to persist build ID on chat binding", + slog.F("chat_id", options.ChatID), + slog.F("build_id", startBuild.ID), + slog.Error(bindErr), + ) + } else if options.OnChatUpdated != nil { + options.OnChatUpdated(updatedChat) + } + if err := waitForBuild(ctx, options.DB, startBuild.ID); err != nil { + return buildToolResponse(newBuildError( xerrors.Errorf("workspace start build failed: %w", err).Error(), - ), nil + startBuild.ID, + )), nil } - return waitForAgentAndRespond(ctx, options.DB, options.AgentConnFn, ws) - }, - ) + return waitForAgentAndRespond(ctx, options.DB, options.AgentConnFn, ws, startBuild.ID) + }) } // waitForAgentAndRespond selects the chat agent from the workspace's // latest build, waits for it to become reachable, and returns a -// success response. +// success response. When buildID is non-zero, it is included in the +// response so the frontend can fetch historical build logs. Pass +// uuid.Nil when no build was triggered (e.g. workspace already +// running); the response will include no_build: true so the +// frontend can suppress the build-log section. func waitForAgentAndRespond( ctx context.Context, db database.Store, agentConnFn AgentConnFunc, ws database.Workspace, + buildID uuid.UUID, ) (fantasy.ToolResponse, error) { agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, ws.ID) if err != nil || len(agents) == 0 { // Workspace started but no agent found - still report // success so the model knows the workspace is up. - return toolResponse(map[string]any{ + result := map[string]any{ "started": true, "workspace_name": ws.Name, "agent_status": "no_agent", - }), nil + } + setBuildID(result, buildID) + setNoBuild(result, buildID) + return toolResponse(result), nil } selected, err := agentselect.FindChatAgent(agents) if err != nil { - return toolResponse(map[string]any{ + result := map[string]any{ "started": true, "workspace_name": ws.Name, "agent_status": "selection_error", "agent_error": err.Error(), - }), nil + } + setBuildID(result, buildID) + setNoBuild(result, buildID) + return toolResponse(result), nil } result := map[string]any{ "started": true, "workspace_name": ws.Name, } + setBuildID(result, buildID) + setNoBuild(result, buildID) for k, v := range waitForAgentReady(ctx, db, selected.ID, agentConnFn) { result[k] = v } diff --git a/coderd/x/chatd/chattool/startworkspace_test.go b/coderd/x/chatd/chattool/startworkspace_test.go index 119e2deafe..19961be1a6 100644 --- a/coderd/x/chatd/chattool/startworkspace_test.go +++ b/coderd/x/chatd/chattool/startworkspace_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "sync" + "sync/atomic" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -110,6 +112,8 @@ func TestStartWorkspace(t *testing.T) { started, ok := result["started"].(bool) require.True(t, ok) require.True(t, started) + require.Nil(t, result["build_id"], "build_id should not be present when workspace was already running") + require.Equal(t, true, result["no_build"], "no_build should be true when workspace was already running") }) t.Run("AlreadyRunningPrefersChatSuffixAgent", func(t *testing.T) { @@ -346,17 +350,18 @@ func TestStartWorkspace(t *testing.T) { require.NoError(t, err) var startCalled bool + var startBuildID uuid.UUID startFn := func(_ context.Context, _ uuid.UUID, wsID uuid.UUID, req codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { startCalled = true require.Equal(t, codersdk.WorkspaceTransitionStart, req.Transition) require.Equal(t, ws.ID, wsID) - // Simulate start by inserting a new completed "start" build. - dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{ + buildResp := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{ Transition: database.WorkspaceTransitionStart, BuildNumber: 2, }).Do() - return codersdk.WorkspaceBuild{}, nil + startBuildID = buildResp.Build.ID + return codersdk.WorkspaceBuild{ID: buildResp.Build.ID}, nil } agentConnFn := func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { @@ -381,6 +386,280 @@ func TestStartWorkspace(t *testing.T) { started, ok := result["started"].(bool) require.True(t, ok) require.True(t, started) + require.Equal(t, startBuildID.String(), result["build_id"]) + require.Nil(t, result["no_build"], "no_build should not be set when a build was triggered") + }) + + t.Run("InProgressBuild", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(ctx, t, db, user.ID) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + // Create a workspace with a build that is still running. + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStart, + }).Starting().Do() + ws := wsResp.Workspace + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-in-progress-build", + }) + require.NoError(t, err) + + // Wrap the DB so we know exactly when the tool reads + // the job status. The interceptor signals AFTER the + // first GetProvisionerJobByID read completes, so the + // main goroutine can safely complete the build knowing + // the tool already observed Running. + jobRead := make(chan struct{}, 1) + wrappedDB := &jobInterceptStore{Store: db, jobRead: jobRead} + + agentConnFn := func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return nil, func() {}, nil + } + + var onChatUpdatedCalled atomic.Bool + tool := chattool.StartWorkspace(chattool.StartWorkspaceOptions{ + DB: wrappedDB, + OwnerID: user.ID, + ChatID: chat.ID, + AgentConnFn: agentConnFn, + StartFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StartFn should not be called for an in-progress build") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + OnChatUpdated: func(_ database.Chat) { onChatUpdatedCalled.Store(true) }, + }) + + // Run tool.Run in a goroutine. It will see the job as + // Running and enter waitForBuild which polls every 2s. + type toolResult struct { + resp fantasy.ToolResponse + err error + } + done := make(chan toolResult, 1) + go func() { + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}) + done <- toolResult{resp, err} + }() + + // Wait for the tool to read the job status (Running). + testutil.TryReceive(ctx, t, jobRead) + + // Now complete the build. The next poll in waitForBuild + // will see Succeeded and return the build ID. + now := time.Now().UTC() + require.NoError(t, db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: wsResp.Build.JobID, + UpdatedAt: now, + CompletedAt: sql.NullTime{Time: now, Valid: true}, + })) + + res := testutil.TryReceive(ctx, t, done) + require.NoError(t, res.err) + resp := res.resp + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + started, ok := result["started"].(bool) + require.True(t, ok) + require.True(t, started) + require.Equal(t, wsResp.Build.ID.String(), result["build_id"]) + require.True(t, onChatUpdatedCalled.Load(), "OnChatUpdated should be called to notify frontend of build ID") + }) + + t.Run("FailedBuild", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(ctx, t, db, user.ID) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + // Create a workspace with a build that is still running. + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStart, + }).Starting().Do() + ws := wsResp.Workspace + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-failed-build", + }) + require.NoError(t, err) + + jobRead := make(chan struct{}, 1) + wrappedDB := &jobInterceptStore{Store: db, jobRead: jobRead} + + tool := chattool.StartWorkspace(chattool.StartWorkspaceOptions{ + DB: wrappedDB, + OwnerID: user.ID, + ChatID: chat.ID, + AgentConnFn: func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return nil, func() {}, nil + }, + StartFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StartFn should not be called for an in-progress build") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + type toolResult struct { + resp fantasy.ToolResponse + err error + } + done := make(chan toolResult, 1) + go func() { + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}) + done <- toolResult{resp, err} + }() + + // Wait for the tool to observe the running job. + testutil.TryReceive(ctx, t, jobRead) + + // Fail the build. + now := time.Now().UTC() + require.NoError(t, db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: wsResp.Build.JobID, + UpdatedAt: now, + CompletedAt: sql.NullTime{Time: now, Valid: true}, + Error: sql.NullString{String: "terraform apply failed", Valid: true}, + })) + + res := testutil.TryReceive(ctx, t, done) + require.NoError(t, res.err) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(res.resp.Content), &result)) + require.Contains(t, result["error"], "waiting for in-progress build") + require.Equal(t, wsResp.Build.ID.String(), result["build_id"]) + require.False(t, res.resp.IsError, + "buildToolResponse must not set IsError; chatprompt strips structured fields from error responses") + }) + + t.Run("StartTriggeredBuildFailure", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(ctx, t, db, user.ID) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + // Create a stopped workspace (succeeded stop transition). + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStop, + }).Do() + ws := wsResp.Workspace + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + Status: database.ChatStatusWaiting, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-start-triggered-build-failure", + }) + require.NoError(t, err) + + // StartFn creates a real in-progress build via dbfake. + var startBuildJobID uuid.UUID + var startBuildID uuid.UUID + startFn := func(_ context.Context, _ uuid.UUID, wsID uuid.UUID, req codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + require.Equal(t, codersdk.WorkspaceTransitionStart, req.Transition) + require.Equal(t, ws.ID, wsID) + buildResp := dbfake.WorkspaceBuild(t, db, ws).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStart, + BuildNumber: 2, + }).Starting().Do() + startBuildJobID = buildResp.Build.JobID + startBuildID = buildResp.Build.ID + return codersdk.WorkspaceBuild{ID: buildResp.Build.ID}, nil + } + + jobRead := make(chan struct{}, 2) + wrappedDB := &jobInterceptStore{Store: db, jobRead: jobRead} + + tool := chattool.StartWorkspace(chattool.StartWorkspaceOptions{ + DB: wrappedDB, + OwnerID: user.ID, + ChatID: chat.ID, + StartFn: startFn, + AgentConnFn: func(_ context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return nil, func() {}, nil + }, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + type toolResult struct { + resp fantasy.ToolResponse + err error + } + done := make(chan toolResult, 1) + go func() { + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}) + done <- toolResult{resp, err} + }() + + // First signal: initial GetProvisionerJobByID for the + // old stop build. Second signal: waitForBuild's first + // poll for the new start build. + testutil.TryReceive(ctx, t, jobRead) + testutil.TryReceive(ctx, t, jobRead) + + // Fail the provisioner job. + now := time.Now().UTC() + require.NoError(t, db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: startBuildJobID, + UpdatedAt: now, + CompletedAt: sql.NullTime{Time: now, Valid: true}, + Error: sql.NullString{String: "terraform apply failed", Valid: true}, + })) + + res := testutil.TryReceive(ctx, t, done) + require.NoError(t, res.err) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(res.resp.Content), &result)) + require.Contains(t, result["error"], "workspace start build failed") + require.Equal(t, startBuildID.String(), result["build_id"]) + require.False(t, res.resp.IsError, + "buildToolResponse must not set IsError; chatprompt strips structured fields from error responses") }) t.Run("DeletedWorkspace", func(t *testing.T) { @@ -466,3 +745,21 @@ func seedModelConfig( require.NoError(t, err) return model } + +// jobInterceptStore wraps a database.Store and signals a +// channel after the first GetProvisionerJobByID read completes. +// This lets the test synchronize: the tool observes the Running +// job status before the main goroutine completes the build. +type jobInterceptStore struct { + database.Store + jobRead chan struct{} +} + +func (s *jobInterceptStore) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) { + result, err := s.Store.GetProvisionerJobByID(ctx, id) + select { + case s.jobRead <- struct{}{}: + default: + } + return result, err +} diff --git a/site/src/api/queries/workspaceBuilds.ts b/site/src/api/queries/workspaceBuilds.ts index de6ef8408f..2fdbb87536 100644 --- a/site/src/api/queries/workspaceBuilds.ts +++ b/site/src/api/queries/workspaceBuilds.ts @@ -1,6 +1,11 @@ -import type { QueryOptions, UseInfiniteQueryOptions } from "react-query"; +import type { + QueryOptions, + UseInfiniteQueryOptions, + UseQueryOptions, +} from "react-query"; import { API } from "#/api/api"; import type { + ProvisionerJobLog, WorkspaceBuild, WorkspaceBuildParameter, WorkspaceBuildsRequest, @@ -61,6 +66,25 @@ export const infiniteWorkspaceBuilds = ( } satisfies UseInfiniteQueryOptions; }; +function workspaceBuildLogsKey(workspaceBuildId: string) { + return ["workspaceBuilds", workspaceBuildId, "logs"] as const; +} + +// Fetches build logs via REST. Completed build logs are immutable, +// so the query uses infinite staleTime to cache across re-mounts +// (e.g. collapsible expand/collapse cycles). +export function workspaceBuildLogs(workspaceBuildId: string) { + return { + queryKey: workspaceBuildLogsKey(workspaceBuildId), + queryFn: () => API.getWorkspaceBuildLogs(workspaceBuildId), + staleTime: Number.POSITIVE_INFINITY, + gcTime: 10 * 60 * 1000, // 10 minutes. Avoids holding logs in cache forever. + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + } as const satisfies UseQueryOptions; +} + // We use readyAgentsCount to invalidate the query when an agent connects export const workspaceBuildTimings = (workspaceBuildId: string) => { return { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 920878a582..2f0c6aa01c 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1238,6 +1238,7 @@ const AgentChatPage: FC = () => { isArchived={isArchived} workspace={workspace} workspaceAgent={workspaceAgent} + chatBuildId={chatQuery.data?.build_id} store={store} editing={editing} effectiveSelectedModel={effectiveSelectedModel} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index f8baa75568..2056ba48c0 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -44,6 +44,7 @@ import { GitPanel } from "./components/GitPanel/GitPanel"; import { RightPanel } from "./components/RightPanel/RightPanel"; import { SidebarTabView } from "./components/Sidebar/SidebarTabView"; import { TerminalPanel } from "./components/TerminalPanel"; +import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext"; import type { ChatDetailError } from "./utils/usageLimitMessage"; type ChatStoreHandle = ReturnType["store"]; @@ -90,6 +91,7 @@ interface AgentChatPageViewProps { isArchived: boolean; workspaceAgent?: TypesGen.WorkspaceAgent; workspace?: TypesGen.Workspace; + chatBuildId?: string; // Store handle. store: ChatStoreHandle; @@ -180,6 +182,7 @@ export const AgentChatPageView: FC = ({ isArchived, workspaceAgent, workspace, + chatBuildId, store, editing, effectiveSelectedModel, @@ -309,194 +312,198 @@ export const AgentChatPageView: FC = ({ const shouldShowSidebar = showSidebarPanel; return ( - -
- {titleElement} + +
-
- {" "} - onSetShowSidebarPanel((prev) => !prev), - }} - workspace={{ - canOpenEditors, - canOpenWorkspace, - onOpenInEditor: handleOpenInEditor, - onViewWorkspace: handleViewWorkspace, - onOpenTerminal: handleOpenTerminal, - sshCommand, - }} - onArchiveAgent={handleArchiveAgentAction} - onUnarchiveAgent={handleUnarchiveAgentAction} - onArchiveAndDeleteWorkspace={ - handleArchiveAndDeleteWorkspaceAction - } - {...(handleRegenerateTitle - ? { onRegenerateTitle: handleRegenerateTitle } - : {})} - isRegeneratingTitle={isRegeneratingTitle} - isRegenerateTitleDisabled={isRegenerateTitleDisabled} - hasWorkspace={Boolean(workspace)} - isArchived={isArchived} - diffStatusData={diffStatusData} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebarCollapsed={onToggleSidebarCollapsed} - /> - {isArchived && ( -
- - This agent has been archived and is read-only. -
+ {titleElement} +
-
- -
- + {" "} + onSetShowSidebarPanel((prev) => !prev), + }} + workspace={{ + canOpenEditors, + canOpenWorkspace, + onOpenInEditor: handleOpenInEditor, + onViewWorkspace: handleViewWorkspace, + onOpenTerminal: handleOpenTerminal, + sshCommand, + }} + onArchiveAgent={handleArchiveAgentAction} + onUnarchiveAgent={handleUnarchiveAgentAction} + onArchiveAndDeleteWorkspace={ + handleArchiveAndDeleteWorkspaceAction + } + {...(handleRegenerateTitle + ? { onRegenerateTitle: handleRegenerateTitle } + : {})} + isRegeneratingTitle={isRegeneratingTitle} + isRegenerateTitleDisabled={isRegenerateTitleDisabled} + hasWorkspace={Boolean(workspace)} + isArchived={isArchived} + diffStatusData={diffStatusData} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + /> + {isArchived && ( +
+ + This agent has been archived and is read-only. +
+ )} +
+
+ +
+ +
+
+
+
- -
-
-
- setIsRightPanelExpanded((prev) => !prev)} - onClose={() => onSetShowSidebarPanel(false)} - onVisualExpandedChange={setDragVisualExpanded} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebarCollapsed={onToggleSidebarCollapsed} - > - - ), - }, - ...(workspace && workspaceAgent - ? [ - { - id: "terminal", - label: "Terminal", - content: ( - - ), - }, - ] - : []), - ]} - onClose={() => onSetShowSidebarPanel(false)} - isExpanded={visualExpanded} + setIsRightPanelExpanded((prev) => !prev)} + onClose={() => onSetShowSidebarPanel(false)} + onVisualExpandedChange={setDragVisualExpanded} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} - chatTitle={chatTitle} - desktopChatId={ - workspace && workspaceAgent ? desktopChatId : undefined - } - /> - -
- + > + + ), + }, + ...(workspace && workspaceAgent + ? [ + { + id: "terminal", + label: "Terminal", + content: ( + + ), + }, + ] + : []), + ]} + onClose={() => onSetShowSidebarPanel(false)} + isExpanded={visualExpanded} + onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + chatTitle={chatTitle} + desktopChatId={ + workspace && workspaceAgent ? desktopChatId : undefined + } + /> + +
+
+
); }; diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index a2ffa7535b..e63f4696b9 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -575,6 +575,7 @@ const AgentsPage: FC = () => { : c.diff_status; const nextWorkspaceId = updatedChat.workspace_id ?? c.workspace_id; + const nextBuildId = updatedChat.build_id ?? c.build_id; const nextUpdatedAt = c.updated_at > updatedChat.updated_at ? c.updated_at @@ -593,6 +594,7 @@ const AgentsPage: FC = () => { nextTitle === c.title && diffStatusEqual(nextDiffStatus, c.diff_status) && nextWorkspaceId === c.workspace_id && + nextBuildId === c.build_id && nextHasUnread === c.has_unread ) { return c; @@ -604,6 +606,7 @@ const AgentsPage: FC = () => { title: nextTitle, diff_status: nextDiffStatus, workspace_id: nextWorkspaceId, + build_id: nextBuildId, updated_at: nextUpdatedAt, has_unread: nextHasUnread, }; @@ -634,6 +637,7 @@ const AgentsPage: FC = () => { : previousChat.diff_status; const nextWorkspaceId = updatedChat.workspace_id ?? previousChat.workspace_id; + const nextBuildId = updatedChat.build_id ?? previousChat.build_id; const nextUpdatedAt = previousChat.updated_at > updatedChat.updated_at ? previousChat.updated_at @@ -643,7 +647,8 @@ const AgentsPage: FC = () => { nextStatus === previousChat.status && nextTitle === previousChat.title && diffStatusEqual(nextDiffStatus, previousChat.diff_status) && - nextWorkspaceId === previousChat.workspace_id + nextWorkspaceId === previousChat.workspace_id && + nextBuildId === previousChat.build_id ) { return previousChat; } @@ -653,6 +658,7 @@ const AgentsPage: FC = () => { title: nextTitle, diff_status: nextDiffStatus, workspace_id: nextWorkspaceId, + build_id: nextBuildId, updated_at: nextUpdatedAt, }; }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx index c8321d9b93..307e71360a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/CreateWorkspaceTool.tsx @@ -1,4 +1,9 @@ -import { ExternalLinkIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react"; +import { + ExternalLinkIcon, + LoaderIcon, + MonitorIcon, + TriangleAlertIcon, +} from "lucide-react"; import type React from "react"; import { Link } from "react-router"; import { @@ -6,14 +11,16 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; -import { cn } from "#/utils/cn"; +import { ToolCollapsible } from "./ToolCollapsible"; import { asRecord, asString, type ToolStatus } from "./utils"; +import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection"; /** * Rendering for `create_workspace` tool calls. * - * Shows "Creating workspace…" while running, and "Created " when - * complete with a link to view the workspace. + * Shows "Creating workspace…" while running with streaming build logs, + * and "Created " when complete with a link to view the workspace. + * Build logs are available in a collapsible section. */ export const CreateWorkspaceTool: React.FC<{ workspaceName: string; @@ -21,7 +28,17 @@ export const CreateWorkspaceTool: React.FC<{ status: ToolStatus; isError: boolean; errorMessage?: string; -}> = ({ workspaceName, resultJson, status, isError, errorMessage }) => { + buildId?: string; + created?: boolean; +}> = ({ + workspaceName, + resultJson, + status, + isError, + errorMessage, + buildId, + created = true, +}) => { const isRunning = status === "running"; let rec: Record | null = null; if (resultJson) { @@ -39,38 +56,55 @@ export const CreateWorkspaceTool: React.FC<{ const label = isRunning ? "Creating workspace…" - : wsName - ? `Created ${wsName}` - : "Created workspace"; + : isError + ? `Failed to create ${wsName || "workspace"}` + : created === false + ? `Workspace ${wsName} already exists` + : wsName + ? `Created ${wsName}` + : "Created workspace"; + + const hasBuildLogs = isRunning || Boolean(buildId); + + const header = ( + <> + + {label} + {isError && ( + + + + + + {errorMessage || "Failed to create workspace"} + + + )} + {isRunning && ( + + )} + {workspaceLink && !isRunning && ( + e.stopPropagation()} + className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100" + aria-label="View workspace" + > + + + )} + + ); return (
-
- {label} - {isError && ( - - - - - - {errorMessage || "Failed to create workspace"} - - - )} - {isRunning && ( - - )} - {workspaceLink && !isRunning && ( - e.stopPropagation()} - className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100" - aria-label="View workspace" - > - - - )} -
+ + +
); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx new file mode 100644 index 0000000000..3195d7efb5 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/StartWorkspaceTool.tsx @@ -0,0 +1,73 @@ +import { LoaderIcon, MonitorPlayIcon, TriangleAlertIcon } from "lucide-react"; +import type { FC } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { ToolCollapsible } from "./ToolCollapsible"; +import type { ToolStatus } from "./utils"; +import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection"; + +interface StartWorkspaceToolProps { + status: ToolStatus; + buildId?: string; + workspaceName: string; + isError: boolean; + errorMessage?: string; + noBuild?: boolean; +} + +export const StartWorkspaceTool: FC = ({ + status, + buildId, + workspaceName, + isError, + errorMessage, + noBuild, +}) => { + const isRunning = status === "running"; + + const label = isRunning + ? "Starting workspace…" + : isError + ? `Failed to start ${workspaceName || "workspace"}` + : workspaceName + ? `Started ${workspaceName}` + : "Started workspace"; + + const header = ( + <> + + {label} + {isError && ( + + + + + + {errorMessage || "Failed to start workspace"} + + + )} + {isRunning && ( + + )} + + ); + + // Show collapsible with build logs when there's a build to show. + const hasBuildLogs = (isRunning || Boolean(buildId)) && !noBuild; + + return ( +
+ + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 42cd297038..d4c70b80fa 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext"; import { DesktopPanelContext } from "./DesktopPanelContext"; import { Tool } from "./Tool"; @@ -1427,6 +1428,27 @@ export const StartWorkspaceRunning: Story = { name: "start_workspace", status: "running", }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + queries: [ + { + key: ["workspace", "test-workspace-id"], + data: { + id: "test-workspace-id", + latest_build: { + id: "test-build-id", + status: "starting", + }, + }, + }, + ], + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText("Starting workspace…")).toBeInTheDocument(); @@ -1441,14 +1463,42 @@ export const StartWorkspaceCompleted: Story = { started: true, workspace_name: "my-project", agent_status: "ready", + build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", }, }, + parameters: { + queries: [ + { + key: [ + "workspaceBuilds", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "logs", + ], + data: [], + }, + ], + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText("Started my-project")).toBeInTheDocument(); }, }; +export const StartWorkspaceLegacy: Story = { + args: { + name: "start_workspace", + status: "completed", + result: { + started: true, + workspace_name: "legacy-workspace", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Started legacy-workspace")).toBeInTheDocument(); + }, +}; + export const StartWorkspaceError: Story = { args: { name: "start_workspace", @@ -1458,4 +1508,173 @@ export const StartWorkspaceError: Story = { error: "workspace was deleted; use create_workspace to make a new one", }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Failed to start workspace")).toBeInTheDocument(); + }, +}; + +export const StartWorkspaceBuildFailed: Story = { + args: { + name: "start_workspace", + status: "completed", + result: { + error: "workspace start build failed: terraform apply failed", + build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, + }, + parameters: { + queries: [ + { + key: [ + "workspaceBuilds", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "logs", + ], + data: [], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Failed to start workspace")).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// create_workspace stories +// --------------------------------------------------------------------------- + +export const CreateWorkspaceRunning: Story = { + args: { + name: "create_workspace", + status: "running", + }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + queries: [ + { + key: ["workspace", "test-workspace-id"], + data: { + id: "test-workspace-id", + latest_build: { + id: "test-build-id", + status: "starting", + }, + }, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Creating workspace…")).toBeInTheDocument(); + }, +}; + +export const CreateWorkspaceCompleted: Story = { + args: { + name: "create_workspace", + status: "completed", + result: { + created: true, + workspace_name: "my-project", + build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, + }, + parameters: { + queries: [ + { + key: [ + "workspaceBuilds", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "logs", + ], + data: [], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Created my-project")).toBeInTheDocument(); + }, +}; + +export const CreateWorkspaceLegacy: Story = { + args: { + name: "create_workspace", + status: "completed", + result: { + created: true, + workspace_name: "legacy-workspace", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Created legacy-workspace")).toBeInTheDocument(); + }, +}; + +export const CreateWorkspaceAlreadyExists: Story = { + args: { + name: "create_workspace", + status: "completed", + result: { + created: false, + workspace_name: "my-project", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("Workspace my-project already exists"), + ).toBeInTheDocument(); + }, +}; + +export const CreateWorkspaceError: Story = { + args: { + name: "create_workspace", + status: "error", + isError: true, + result: { + error: "template not found", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Failed to create workspace")).toBeInTheDocument(); + }, +}; + +export const CreateWorkspaceBuildFailed: Story = { + args: { + name: "create_workspace", + status: "completed", + result: { + error: "workspace build failed: terraform apply failed", + build_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, + }, + parameters: { + queries: [ + { + key: [ + "workspaceBuilds", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "logs", + ], + data: [], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Failed to create workspace")).toBeInTheDocument(); + }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 4bb0b6ddf0..e611c1132c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -25,6 +25,7 @@ import { ProposePlanTool } from "./ProposePlanTool"; import { ReadFileTool } from "./ReadFileTool"; import { ReadSkillTool } from "./ReadSkillTool"; import { ReadTemplateTool } from "./ReadTemplateTool"; +import { StartWorkspaceTool } from "./StartWorkspaceTool"; import { SubagentTool } from "./SubagentTool"; import { ToolCollapsible } from "./ToolCollapsible"; import { ToolIcon } from "./ToolIcon"; @@ -314,15 +315,20 @@ const CreateWorkspaceRenderer: FC = ({ }) => { const rec = asRecord(result); const wsName = rec ? asString(rec.workspace_name) : ""; + const buildId = rec ? asString(rec.build_id) : undefined; const resultJson = rec ? JSON.stringify(rec, null, 2) : ""; + const hasErrorInResult = Boolean(rec?.error); + const created = rec?.created !== false; return ( ); }; @@ -704,6 +710,29 @@ const ProcessSignalRenderer: FC = (props) => { ); }; +const StartWorkspaceRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const wsName = rec ? asString(rec.workspace_name) : ""; + const buildId = rec ? asString(rec.build_id) : undefined; + const hasErrorInResult = Boolean(rec?.error); + const noBuild = Boolean(rec?.no_build); + + return ( + + ); +}; + // --------------------------------------------------------------------------- // Renderer lookup map — maps tool names to their specialized renderers. // --------------------------------------------------------------------------- @@ -717,6 +746,7 @@ const toolRenderers: Record> = { write_file: WriteFileRenderer, edit_files: EditFilesRenderer, create_workspace: CreateWorkspaceRenderer, + start_workspace: StartWorkspaceRenderer, list_templates: ListTemplatesRenderer, read_template: ReadTemplateRenderer, read_skill: ReadSkillRenderer, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.stories.tsx new file mode 100644 index 0000000000..ed12d47d60 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.stories.tsx @@ -0,0 +1,171 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import type { ProvisionerJobLog } from "#/api/typesGenerated"; +import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext"; +import { WorkspaceBuildLogSection } from "./WorkspaceBuildLogSection"; + +const TEST_BUILD_ID = "test-build-id-000"; +const TEST_WORKSPACE_ID = "test-workspace-id-000"; + +const sampleLogs: ProvisionerJobLog[] = [ + { + id: 1, + created_at: "2024-01-01T00:00:00Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Initializing Terraform...", + }, + { + id: 2, + created_at: "2024-01-01T00:00:01Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Terraform has been successfully initialized!", + }, + { + id: 3, + created_at: "2024-01-01T00:00:02Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Apply complete! Resources: 2 added, 0 changed, 0 destroyed.", + }, +]; + +const meta: Meta = { + title: "pages/AgentsPage/ChatElements/tools/WorkspaceBuildLogSection", + component: WorkspaceBuildLogSection, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Build ID is present but the REST fetch has not resolved yet. */ +export const Loading: Story = { + args: { + status: "completed", + buildId: TEST_BUILD_ID, + }, + beforeEach: () => { + spyOn(API, "getWorkspaceBuildLogs").mockImplementation( + () => new Promise(() => {}), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Loading build logs\u2026")).toBeInTheDocument(); + }, +}; + +/** Completed build with logs fetched from the REST endpoint. */ +export const CompletedWithLogs: Story = { + args: { + status: "completed", + buildId: TEST_BUILD_ID, + }, + parameters: { + queries: [ + { + key: ["workspaceBuilds", TEST_BUILD_ID, "logs"], + data: sampleLogs, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Starting workspace")).toBeInTheDocument(); + }); + }, +}; + +/** REST fetch for build logs returned a server error. */ +export const FetchError: Story = { + args: { + status: "completed", + buildId: TEST_BUILD_ID, + }, + beforeEach: () => { + spyOn(API, "getWorkspaceBuildLogs").mockRejectedValue( + new Error("Internal Server Error"), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText("Failed to load build logs."), + ).toBeInTheDocument(); + }); + }, +}; + +/** + * Build completed with zero log output. The REST query succeeds but + * returns an empty array, so the component shows "No build logs + * available." instead of a perpetual spinner. + */ +export const CompletedEmptyLogs: Story = { + args: { + status: "completed", + buildId: TEST_BUILD_ID, + }, + parameters: { + queries: [ + { + key: ["workspaceBuilds", TEST_BUILD_ID, "logs"], + data: [], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("No build logs available.")).toBeInTheDocument(); + }); + }, +}; + +/** + * Tool is running with an active build in progress. The workspace + * query returns a latest_build with status="starting", so the + * component derives an activeBuildId and shows the loading state + * while waiting for the WebSocket stream. + */ +export const Running: Story = { + args: { + status: "running", + }, + parameters: { + queries: [ + { + key: ["workspace", TEST_WORKSPACE_ID], + data: { + id: TEST_WORKSPACE_ID, + latest_build: { + id: TEST_BUILD_ID, + status: "starting", + }, + }, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Loading build logs\u2026")).toBeInTheDocument(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx new file mode 100644 index 0000000000..e37e0eabfd --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx @@ -0,0 +1,172 @@ +import { LoaderIcon, TriangleAlertIcon } from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { useQuery } from "react-query"; +import { workspaceBuildLogs } from "#/api/queries/workspaceBuilds"; +import { workspaceById } from "#/api/queries/workspaces"; +import type { ProvisionerJobLog } from "#/api/typesGenerated"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { useWorkspaceBuildLogs } from "#/hooks/useWorkspaceBuildLogs"; +import { WorkspaceBuildLogs } from "#/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; +import { + useChatBuildId, + useChatWorkspaceId, +} from "../../../context/ChatWorkspaceContext"; +import type { ToolStatus } from "./utils"; + +interface WorkspaceBuildLogSectionProps { + status: ToolStatus; + /** Build ID from the completed tool result. */ + buildId?: string; +} + +// How long to wait for the first log entry before showing a +// warning. Builds can stay queued or run slow Terraform init for +// longer than this. The message is intentionally soft. +const LOG_LOAD_TIMEOUT_MS = 30_000; + +/** + * Streams or fetches workspace build logs for display inside a tool + * collapsible. While the tool is running, logs stream via WebSocket + * from the build tracked by the chat binding (or the workspace's + * latest active build as a fallback). Once completed, logs + * are fetched via REST and cached by React Query so expand/collapse + * cycles don't re-fetch. + */ +export const WorkspaceBuildLogSection: FC = ({ + status, + buildId, +}) => { + const isRunning = status === "running"; + + // Primary source: build ID from the chat binding, pushed via + // pubsub when create_workspace or start_workspace persists it. + // This avoids the 2s polling latency. + const chatBuildId = useChatBuildId(); + + // Fallback: poll the workspace to infer the build ID from + // latest_build. Only used when the binding hasn't arrived yet. + const workspaceId = useChatWorkspaceId(); + const needsPoll = isRunning && !chatBuildId; + const workspaceQuery = useQuery({ + ...workspaceById(workspaceId ?? ""), + enabled: needsPoll && Boolean(workspaceId), + refetchInterval: needsPoll ? 2000 : false, + }); + const liveBuildId = workspaceQuery.data?.latest_build?.id; + + // Only use the polled build if it's actually in progress. + const latestBuildStatus = workspaceQuery.data?.latest_build?.status; + const polledActiveBuildId = + latestBuildStatus === "pending" || latestBuildStatus === "starting" + ? liveBuildId + : undefined; + + // While running: prefer chat binding (instant via pubsub), + // fall back to polled workspace (2s latency). When completed: + // use the build ID from the tool result. + const effectiveBuildId = isRunning + ? (chatBuildId ?? polledActiveBuildId) + : buildId; + + // --- Running builds: stream via WebSocket --- + const streamingLogs = useWorkspaceBuildLogs( + isRunning ? effectiveBuildId : undefined, + isRunning && Boolean(effectiveBuildId), + ); + + // --- Completed builds: fetch via REST (cached across mounts) --- + const completedLogsQuery = useQuery({ + ...workspaceBuildLogs(effectiveBuildId ?? ""), + enabled: !isRunning && Boolean(effectiveBuildId), + }); + + const logs: ProvisionerJobLog[] | undefined = isRunning + ? streamingLogs + : // Fall back to accumulated streaming logs while the REST + // fetch is in-flight, avoiding a flash of "Loading…" on + // the running→completed transition. + (completedLogsQuery.data ?? streamingLogs); + + // --- Timeout: detect if logs never arrive --- + // Derive a stable boolean so the effect only re-runs when logs + // first appear or when the build ID changes, not on every + // appended log entry. + const hasLogs = Boolean(logs && logs.length > 0); + const [timedOut, setTimedOut] = useState(false); + useEffect(() => { + setTimedOut(false); + if (!effectiveBuildId || hasLogs) { + return; + } + const timer = setTimeout(() => setTimedOut(true), LOG_LOAD_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [effectiveBuildId, hasLogs]); + + const fetchFailed = !isRunning && completedLogsQuery.isError; + + if (!effectiveBuildId) { + if (isRunning && workspaceId) { + return ( +
+ + Loading build logs… +
+ ); + } + return null; + } + + if (fetchFailed) { + return ( +
+ + Failed to load build logs. +
+ ); + } + + if (timedOut && !hasLogs) { + return ( +
+ + Build logs are taking longer than expected. +
+ ); + } + + // Query succeeded but the build produced no log output. + if ( + !isRunning && + completedLogsQuery.isSuccess && + (!logs || logs.length === 0) + ) { + return ( +
+ No build logs available. +
+ ); + } + + if (!logs || logs.length === 0) { + return ( +
+ + Loading build logs… +
+ ); + } + + return ( + + + + ); +}; diff --git a/site/src/pages/AgentsPage/context/ChatWorkspaceContext.tsx b/site/src/pages/AgentsPage/context/ChatWorkspaceContext.tsx new file mode 100644 index 0000000000..3268da51d8 --- /dev/null +++ b/site/src/pages/AgentsPage/context/ChatWorkspaceContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; + +interface ChatWorkspaceContextValue { + workspaceId?: string; + buildId?: string; +} + +const ChatWorkspaceContext = createContext({}); + +/** + * Returns the workspace ID associated with the current chat, if any. + * Use this in tool renderers that need workspace data during execution. + */ +export const useChatWorkspaceId = () => + useContext(ChatWorkspaceContext).workspaceId; + +/** + * Returns the build ID from the chat binding, if any. + * This is set when create_workspace or start_workspace persists + * the build ID via UpdateChatWorkspaceBinding, and arrives on the + * frontend through the chat watch event without polling. + */ +export const useChatBuildId = () => useContext(ChatWorkspaceContext).buildId; + +export { ChatWorkspaceContext };