feat: show build logs in chat for start_workspace and create_workspace tools (#24194)

This commit is contained in:
Danielle Maywood
2026-04-12 15:04:10 +01:00
committed by GitHub
parent 982739f3bf
commit cb0b84a2d3
17 changed files with 1941 additions and 339 deletions
+16 -7
View File
@@ -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.
+47 -1
View File
@@ -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.
+116 -70
View File
@@ -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)
+403 -23
View File
@@ -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(
+87 -25
View File
@@ -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
}
+300 -3
View File
@@ -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
}