mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: show build logs in chat for start_workspace and create_workspace tools (#24194)
This commit is contained in:
+16
-7
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceBuild[]>;
|
||||
};
|
||||
|
||||
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<ProvisionerJobLog[]>;
|
||||
}
|
||||
|
||||
// We use readyAgentsCount to invalidate the query when an agent connects
|
||||
export const workspaceBuildTimings = (workspaceBuildId: string) => {
|
||||
return {
|
||||
|
||||
@@ -1238,6 +1238,7 @@ const AgentChatPage: FC = () => {
|
||||
isArchived={isArchived}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
chatBuildId={chatQuery.data?.build_id}
|
||||
store={store}
|
||||
editing={editing}
|
||||
effectiveSelectedModel={effectiveSelectedModel}
|
||||
|
||||
@@ -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<typeof useChatStore>["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<AgentChatPageViewProps> = ({
|
||||
isArchived,
|
||||
workspaceAgent,
|
||||
workspace,
|
||||
chatBuildId,
|
||||
store,
|
||||
editing,
|
||||
effectiveSelectedModel,
|
||||
@@ -309,194 +312,198 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
const shouldShowSidebar = showSidebarPanel;
|
||||
|
||||
return (
|
||||
<DesktopPanelContext value={desktopPanelCtx}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1",
|
||||
shouldShowSidebar && !visualExpanded && "flex-row",
|
||||
)}
|
||||
>
|
||||
{titleElement}
|
||||
<ChatWorkspaceContext
|
||||
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
|
||||
>
|
||||
<DesktopPanelContext value={desktopPanelCtx}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
visualExpanded && "hidden",
|
||||
shouldShowSidebar && "max-lg:hidden",
|
||||
"relative flex min-h-0 min-w-0 flex-1",
|
||||
shouldShowSidebar && !visualExpanded && "flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 shrink-0 overflow-visible">
|
||||
{" "}
|
||||
<ChatTopBar
|
||||
chatTitle={chatTitle}
|
||||
parentChat={parentChat}
|
||||
panel={{
|
||||
showSidebarPanel,
|
||||
onToggleSidebar: () => 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 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
|
||||
<ArchiveIcon className="h-4 w-4 shrink-0" />
|
||||
This agent has been archived and is read-only.
|
||||
</div>
|
||||
{titleElement}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
visualExpanded && "hidden",
|
||||
shouldShowSidebar && "max-lg:hidden",
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ChatScrollContainer
|
||||
key={agentId}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollToBottomRef={effectiveScrollToBottomRef}
|
||||
isFetchingMoreMessages={isFetchingMoreMessages}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
onFetchMoreMessages={onFetchMoreMessages}
|
||||
>
|
||||
<div className="px-4">
|
||||
<ChatPageTimeline
|
||||
chatID={agentId}
|
||||
store={store}
|
||||
persistedError={persistedError}
|
||||
onEditUserMessage={editing.handleEditUserMessage}
|
||||
editingMessageId={editing.editingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
<div className="relative z-10 shrink-0 overflow-visible">
|
||||
{" "}
|
||||
<ChatTopBar
|
||||
chatTitle={chatTitle}
|
||||
parentChat={parentChat}
|
||||
panel={{
|
||||
showSidebarPanel,
|
||||
onToggleSidebar: () => 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 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
|
||||
<ArchiveIcon className="h-4 w-4 shrink-0" />
|
||||
This agent has been archived and is read-only.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ChatScrollContainer
|
||||
key={agentId}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollToBottomRef={effectiveScrollToBottomRef}
|
||||
isFetchingMoreMessages={isFetchingMoreMessages}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
onFetchMoreMessages={onFetchMoreMessages}
|
||||
>
|
||||
<div className="px-4">
|
||||
<ChatPageTimeline
|
||||
chatID={agentId}
|
||||
store={store}
|
||||
persistedError={persistedError}
|
||||
onEditUserMessage={editing.handleEditUserMessage}
|
||||
editingMessageId={editing.editingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
/>
|
||||
</div>
|
||||
</ChatScrollContainer>
|
||||
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<ChatPageInput
|
||||
store={store}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onSend={editing.handleSendFromInput}
|
||||
onDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={handlePromoteQueuedMessage}
|
||||
onInterrupt={handleInterrupt}
|
||||
isInputDisabled={isInputDisabled}
|
||||
isSendPending={isSubmissionPending}
|
||||
isInterruptPending={isInterruptPending}
|
||||
hasModelOptions={hasModelOptions}
|
||||
selectedModel={effectiveSelectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
modelSelectorHelp={modelSelectorHelp}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
inputRef={editing.chatInputRef}
|
||||
initialValue={editing.editorInitialValue}
|
||||
initialEditorState={editing.initialEditorState}
|
||||
remountKey={editing.remountKey}
|
||||
onContentChange={editing.handleContentChange}
|
||||
editingQueuedMessageID={editing.editingQueuedMessageID}
|
||||
onStartQueueEdit={editing.handleStartQueueEdit}
|
||||
onCancelQueueEdit={editing.handleCancelQueueEdit}
|
||||
isEditingHistoryMessage={editing.editingMessageId !== null}
|
||||
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
|
||||
onEditUserMessage={editing.handleEditUserMessage}
|
||||
editingFileBlocks={editing.editingFileBlocks}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
lastInjectedContext={lastInjectedContext}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
/>
|
||||
</div>
|
||||
</ChatScrollContainer>
|
||||
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<ChatPageInput
|
||||
store={store}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onSend={editing.handleSendFromInput}
|
||||
onDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={handlePromoteQueuedMessage}
|
||||
onInterrupt={handleInterrupt}
|
||||
isInputDisabled={isInputDisabled}
|
||||
isSendPending={isSubmissionPending}
|
||||
isInterruptPending={isInterruptPending}
|
||||
hasModelOptions={hasModelOptions}
|
||||
selectedModel={effectiveSelectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
modelSelectorHelp={modelSelectorHelp}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
inputRef={editing.chatInputRef}
|
||||
initialValue={editing.editorInitialValue}
|
||||
initialEditorState={editing.initialEditorState}
|
||||
remountKey={editing.remountKey}
|
||||
onContentChange={editing.handleContentChange}
|
||||
editingQueuedMessageID={editing.editingQueuedMessageID}
|
||||
onStartQueueEdit={editing.handleStartQueueEdit}
|
||||
onCancelQueueEdit={editing.handleCancelQueueEdit}
|
||||
isEditingHistoryMessage={editing.editingMessageId !== null}
|
||||
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
|
||||
onEditUserMessage={editing.handleEditUserMessage}
|
||||
editingFileBlocks={editing.editingFileBlocks}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
lastInjectedContext={lastInjectedContext}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RightPanel
|
||||
isOpen={shouldShowSidebar}
|
||||
isExpanded={isRightPanelExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
onVisualExpandedChange={setDragVisualExpanded}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
>
|
||||
<SidebarTabView
|
||||
activeTabId={sidebarTabId}
|
||||
onActiveTabChange={setSidebarTabId}
|
||||
tabs={[
|
||||
{
|
||||
id: "git",
|
||||
label: "Git",
|
||||
content: (
|
||||
<GitPanel
|
||||
prTab={
|
||||
prNumber && agentId
|
||||
? { prNumber, chatId: agentId }
|
||||
: undefined
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
onRefresh={handleRefresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
remoteDiffStats={diffStatusData}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(workspace && workspaceAgent
|
||||
? [
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
content: (
|
||||
<TerminalPanel
|
||||
chatId={agentId}
|
||||
isVisible={
|
||||
shouldShowSidebar && sidebarTabId === "terminal"
|
||||
}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
isExpanded={visualExpanded}
|
||||
<RightPanel
|
||||
isOpen={shouldShowSidebar}
|
||||
isExpanded={isRightPanelExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
onVisualExpandedChange={setDragVisualExpanded}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
chatTitle={chatTitle}
|
||||
desktopChatId={
|
||||
workspace && workspaceAgent ? desktopChatId : undefined
|
||||
}
|
||||
/>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</DesktopPanelContext>
|
||||
>
|
||||
<SidebarTabView
|
||||
activeTabId={sidebarTabId}
|
||||
onActiveTabChange={setSidebarTabId}
|
||||
tabs={[
|
||||
{
|
||||
id: "git",
|
||||
label: "Git",
|
||||
content: (
|
||||
<GitPanel
|
||||
prTab={
|
||||
prNumber && agentId
|
||||
? { prNumber, chatId: agentId }
|
||||
: undefined
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
onRefresh={handleRefresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
remoteDiffStats={diffStatusData}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(workspace && workspaceAgent
|
||||
? [
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
content: (
|
||||
<TerminalPanel
|
||||
chatId={agentId}
|
||||
isVisible={
|
||||
shouldShowSidebar && sidebarTabId === "terminal"
|
||||
}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
isExpanded={visualExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
chatTitle={chatTitle}
|
||||
desktopChatId={
|
||||
workspace && workspaceAgent ? desktopChatId : undefined
|
||||
}
|
||||
/>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</DesktopPanelContext>
|
||||
</ChatWorkspaceContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 <name>" when
|
||||
* complete with a link to view the workspace.
|
||||
* Shows "Creating workspace…" while running with streaming build logs,
|
||||
* and "Created <name>" 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<string, unknown> | 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 = (
|
||||
<>
|
||||
<MonitorIcon className="h-4 w-4 shrink-0 text-content-secondary" />
|
||||
<span className="text-sm text-content-secondary">{label}</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to create workspace"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{workspaceLink && !isRunning && (
|
||||
<Link
|
||||
to={workspaceLink}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
|
||||
aria-label="View workspace"
|
||||
>
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm", "text-content-secondary")}>{label}</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to create workspace"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{workspaceLink && !isRunning && (
|
||||
<Link
|
||||
to={workspaceLink}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
|
||||
aria-label="View workspace"
|
||||
>
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ToolCollapsible
|
||||
header={header}
|
||||
hasContent={hasBuildLogs}
|
||||
defaultExpanded={isRunning}
|
||||
>
|
||||
<WorkspaceBuildLogSection status={status} buildId={buildId} />
|
||||
</ToolCollapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<StartWorkspaceToolProps> = ({
|
||||
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 = (
|
||||
<>
|
||||
<MonitorPlayIcon className="h-4 w-4 shrink-0 text-content-secondary" />
|
||||
<span className="text-sm text-content-secondary">{label}</span>
|
||||
{isError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{errorMessage || "Failed to start workspace"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Show collapsible with build logs when there's a build to show.
|
||||
const hasBuildLogs = (isRunning || Boolean(buildId)) && !noBuild;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ToolCollapsible
|
||||
header={header}
|
||||
hasContent={hasBuildLogs}
|
||||
defaultExpanded={isRunning}
|
||||
>
|
||||
<WorkspaceBuildLogSection status={status} buildId={buildId} />
|
||||
</ToolCollapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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) => (
|
||||
<ChatWorkspaceContext value={{ workspaceId: "test-workspace-id" }}>
|
||||
<Story />
|
||||
</ChatWorkspaceContext>
|
||||
),
|
||||
],
|
||||
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) => (
|
||||
<ChatWorkspaceContext value={{ workspaceId: "test-workspace-id" }}>
|
||||
<Story />
|
||||
</ChatWorkspaceContext>
|
||||
),
|
||||
],
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ToolRendererProps> = ({
|
||||
}) => {
|
||||
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 (
|
||||
<CreateWorkspaceTool
|
||||
workspaceName={wsName}
|
||||
resultJson={resultJson}
|
||||
status={status}
|
||||
isError={isError}
|
||||
isError={isError || hasErrorInResult}
|
||||
errorMessage={rec ? asString(rec.error || rec.reason) : undefined}
|
||||
buildId={buildId}
|
||||
created={created}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -704,6 +710,29 @@ const ProcessSignalRenderer: FC<ToolRendererProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const StartWorkspaceRenderer: FC<ToolRendererProps> = ({
|
||||
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 (
|
||||
<StartWorkspaceTool
|
||||
status={status}
|
||||
buildId={buildId}
|
||||
workspaceName={wsName}
|
||||
isError={isError || hasErrorInResult}
|
||||
errorMessage={rec ? asString(rec.error || rec.reason) : undefined}
|
||||
noBuild={noBuild}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer lookup map — maps tool names to their specialized renderers.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -717,6 +746,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
|
||||
write_file: WriteFileRenderer,
|
||||
edit_files: EditFilesRenderer,
|
||||
create_workspace: CreateWorkspaceRenderer,
|
||||
start_workspace: StartWorkspaceRenderer,
|
||||
list_templates: ListTemplatesRenderer,
|
||||
read_template: ReadTemplateRenderer,
|
||||
read_skill: ReadSkillRenderer,
|
||||
|
||||
+171
@@ -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<typeof WorkspaceBuildLogSection> = {
|
||||
title: "pages/AgentsPage/ChatElements/tools/WorkspaceBuildLogSection",
|
||||
component: WorkspaceBuildLogSection,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ChatWorkspaceContext value={{ workspaceId: TEST_WORKSPACE_ID }}>
|
||||
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
|
||||
<Story />
|
||||
</div>
|
||||
</ChatWorkspaceContext>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspaceBuildLogSection>;
|
||||
|
||||
/** 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();
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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<WorkspaceBuildLogSectionProps> = ({
|
||||
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 (
|
||||
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
|
||||
<LoaderIcon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
|
||||
<span>Loading build logs…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fetchFailed) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
|
||||
<TriangleAlertIcon className="h-3 w-3" />
|
||||
<span>Failed to load build logs.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (timedOut && !hasLogs) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
|
||||
<TriangleAlertIcon className="h-3 w-3" />
|
||||
<span>Build logs are taking longer than expected.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Query succeeded but the build produced no log output.
|
||||
if (
|
||||
!isRunning &&
|
||||
completedLogsQuery.isSuccess &&
|
||||
(!logs || logs.length === 0)
|
||||
) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
|
||||
<span>No build logs available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 px-4 text-xs text-content-secondary">
|
||||
<LoaderIcon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
|
||||
<span>Loading build logs…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
||||
viewportClassName="max-h-64"
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<WorkspaceBuildLogs
|
||||
logs={logs}
|
||||
sticky
|
||||
className="border-0 rounded-none"
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface ChatWorkspaceContextValue {
|
||||
workspaceId?: string;
|
||||
buildId?: string;
|
||||
}
|
||||
|
||||
const ChatWorkspaceContext = createContext<ChatWorkspaceContextValue>({});
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user