Skip to content

Commit a67f5ed

Browse files
authored
Merge branch 'main' into cj/ui-workspace-table-update-icon
2 parents b3402f6 + f44969b commit a67f5ed

File tree

121 files changed

+6442
-1739
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

121 files changed

+6442
-1739
lines changed

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,4 @@ Read [cursor rules](.cursorrules).
103103

104104
The frontend is contained in the site folder.
105105

106-
For building Frontend refer to [this document](docs/contributing/frontend.md)
107106
For building Frontend refer to [this document](docs/about/contributing/frontend.md)

agent/agentcontainers/api.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,11 @@ func (api *API) updaterLoop() {
378378
// and anyone looking to interact with the API.
379379
api.logger.Debug(api.ctx, "performing initial containers update")
380380
if err := api.updateContainers(api.ctx); err != nil {
381-
api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err))
381+
if errors.Is(err, context.Canceled) {
382+
api.logger.Warn(api.ctx, "initial containers update canceled", slog.Error(err))
383+
} else {
384+
api.logger.Error(api.ctx, "initial containers update failed", slog.Error(err))
385+
}
382386
} else {
383387
api.logger.Debug(api.ctx, "initial containers update complete")
384388
}
@@ -399,7 +403,11 @@ func (api *API) updaterLoop() {
399403
case api.updateTrigger <- done:
400404
err := <-done
401405
if err != nil {
402-
api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err))
406+
if errors.Is(err, context.Canceled) {
407+
api.logger.Warn(api.ctx, "updater loop ticker canceled", slog.Error(err))
408+
} else {
409+
api.logger.Error(api.ctx, "updater loop ticker failed", slog.Error(err))
410+
}
403411
}
404412
default:
405413
api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress")

agent/agentcontainers/containers_dockercli.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListCon
311311
// container IDs and returns the parsed output.
312312
// The stderr output is also returned for logging purposes.
313313
func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) (stdout, stderr []byte, err error) {
314+
if ctx.Err() != nil {
315+
// If the context is done, we don't want to run the command.
316+
return []byte{}, []byte{}, ctx.Err()
317+
}
314318
var stdoutBuf, stderrBuf bytes.Buffer
315319
cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
316320
cmd.Stdout = &stdoutBuf
@@ -319,6 +323,12 @@ func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...strin
319323
stdout = bytes.TrimSpace(stdoutBuf.Bytes())
320324
stderr = bytes.TrimSpace(stderrBuf.Bytes())
321325
if err != nil {
326+
if ctx.Err() != nil {
327+
// If the context was canceled while running the command,
328+
// return the context error instead of the command error,
329+
// which is likely to be "signal: killed".
330+
return stdout, stderr, ctx.Err()
331+
}
322332
if bytes.Contains(stderr, []byte("No such object:")) {
323333
// This can happen if a container is deleted between the time we check for its existence and the time we inspect it.
324334
return stdout, stderr, nil

cli/testdata/coder_list_--output_json.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@
6868
"available": 0,
6969
"most_recently_seen": null
7070
},
71-
"template_version_preset_id": null
71+
"template_version_preset_id": null,
72+
"has_ai_task": false
7273
},
7374
"latest_app_status": null,
7475
"outdated": false,

coderd/aitasks.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package coderd
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/google/uuid"
9+
10+
"github.com/coder/coder/v2/coderd/httpapi"
11+
"github.com/coder/coder/v2/codersdk"
12+
)
13+
14+
// This endpoint is experimental and not guaranteed to be stable, so we're not
15+
// generating public-facing documentation for it.
16+
func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) {
17+
ctx := r.Context()
18+
19+
buildIDsParam := r.URL.Query().Get("build_ids")
20+
if buildIDsParam == "" {
21+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
22+
Message: "build_ids query parameter is required",
23+
})
24+
return
25+
}
26+
27+
// Parse build IDs
28+
buildIDStrings := strings.Split(buildIDsParam, ",")
29+
buildIDs := make([]uuid.UUID, 0, len(buildIDStrings))
30+
for _, idStr := range buildIDStrings {
31+
id, err := uuid.Parse(strings.TrimSpace(idStr))
32+
if err != nil {
33+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
34+
Message: fmt.Sprintf("Invalid build ID format: %s", idStr),
35+
Detail: err.Error(),
36+
})
37+
return
38+
}
39+
buildIDs = append(buildIDs, id)
40+
}
41+
42+
parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs)
43+
if err != nil {
44+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
45+
Message: "Internal error fetching workspace build parameters.",
46+
Detail: err.Error(),
47+
})
48+
return
49+
}
50+
51+
promptsByBuildID := make(map[string]string, len(parameters))
52+
for _, param := range parameters {
53+
if param.Name != codersdk.AITaskPromptParameterName {
54+
continue
55+
}
56+
buildID := param.WorkspaceBuildID.String()
57+
promptsByBuildID[buildID] = param.Value
58+
}
59+
60+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{
61+
Prompts: promptsByBuildID,
62+
})
63+
}

coderd/aitasks_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package coderd_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/uuid"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/coderd/coderdtest"
10+
"github.com/coder/coder/v2/coderd/database/dbtestutil"
11+
"github.com/coder/coder/v2/codersdk"
12+
"github.com/coder/coder/v2/provisioner/echo"
13+
"github.com/coder/coder/v2/provisionersdk/proto"
14+
"github.com/coder/coder/v2/testutil"
15+
)
16+
17+
func TestAITasksPrompts(t *testing.T) {
18+
t.Parallel()
19+
20+
t.Run("EmptyBuildIDs", func(t *testing.T) {
21+
t.Parallel()
22+
client := coderdtest.New(t, &coderdtest.Options{})
23+
_ = coderdtest.CreateFirstUser(t, client)
24+
experimentalClient := codersdk.NewExperimentalClient(client)
25+
26+
ctx := testutil.Context(t, testutil.WaitShort)
27+
28+
// Test with empty build IDs
29+
prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{})
30+
require.NoError(t, err)
31+
require.Empty(t, prompts.Prompts)
32+
})
33+
34+
t.Run("MultipleBuilds", func(t *testing.T) {
35+
t.Parallel()
36+
37+
if !dbtestutil.WillUsePostgres() {
38+
t.Skip("This test checks RBAC, which is not supported in the in-memory database")
39+
}
40+
41+
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
42+
first := coderdtest.CreateFirstUser(t, adminClient)
43+
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID)
44+
45+
ctx := testutil.Context(t, testutil.WaitLong)
46+
47+
// Create a template with parameters
48+
version := coderdtest.CreateTemplateVersion(t, adminClient, first.OrganizationID, &echo.Responses{
49+
Parse: echo.ParseComplete,
50+
ProvisionPlan: []*proto.Response{{
51+
Type: &proto.Response_Plan{
52+
Plan: &proto.PlanComplete{
53+
Parameters: []*proto.RichParameter{
54+
{
55+
Name: "param1",
56+
Type: "string",
57+
DefaultValue: "default1",
58+
},
59+
{
60+
Name: codersdk.AITaskPromptParameterName,
61+
Type: "string",
62+
DefaultValue: "default2",
63+
},
64+
},
65+
},
66+
},
67+
}},
68+
ProvisionApply: echo.ApplyComplete,
69+
})
70+
template := coderdtest.CreateTemplate(t, adminClient, first.OrganizationID, version.ID)
71+
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
72+
73+
// Create two workspaces with different parameters
74+
workspace1 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
75+
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
76+
{Name: "param1", Value: "value1a"},
77+
{Name: codersdk.AITaskPromptParameterName, Value: "value2a"},
78+
}
79+
})
80+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace1.LatestBuild.ID)
81+
82+
workspace2 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
83+
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
84+
{Name: "param1", Value: "value1b"},
85+
{Name: codersdk.AITaskPromptParameterName, Value: "value2b"},
86+
}
87+
})
88+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace2.LatestBuild.ID)
89+
90+
workspace3 := coderdtest.CreateWorkspace(t, adminClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
91+
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
92+
{Name: "param1", Value: "value1c"},
93+
{Name: codersdk.AITaskPromptParameterName, Value: "value2c"},
94+
}
95+
})
96+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace3.LatestBuild.ID)
97+
allBuildIDs := []uuid.UUID{workspace1.LatestBuild.ID, workspace2.LatestBuild.ID, workspace3.LatestBuild.ID}
98+
99+
experimentalMemberClient := codersdk.NewExperimentalClient(memberClient)
100+
// Test parameters endpoint as member
101+
prompts, err := experimentalMemberClient.AITaskPrompts(ctx, allBuildIDs)
102+
require.NoError(t, err)
103+
// we expect 2 prompts because the member client does not have access to workspace3
104+
// since it was created by the admin client
105+
require.Len(t, prompts.Prompts, 2)
106+
107+
// Check workspace1 parameters
108+
build1Prompt := prompts.Prompts[workspace1.LatestBuild.ID.String()]
109+
require.Equal(t, "value2a", build1Prompt)
110+
111+
// Check workspace2 parameters
112+
build2Prompt := prompts.Prompts[workspace2.LatestBuild.ID.String()]
113+
require.Equal(t, "value2b", build2Prompt)
114+
115+
experimentalAdminClient := codersdk.NewExperimentalClient(adminClient)
116+
// Test parameters endpoint as admin
117+
// we expect 3 prompts because the admin client has access to all workspaces
118+
prompts, err = experimentalAdminClient.AITaskPrompts(ctx, allBuildIDs)
119+
require.NoError(t, err)
120+
require.Len(t, prompts.Prompts, 3)
121+
122+
// Check workspace3 parameters
123+
build3Prompt := prompts.Prompts[workspace3.LatestBuild.ID.String()]
124+
require.Equal(t, "value2c", build3Prompt)
125+
})
126+
127+
t.Run("NonExistentBuildIDs", func(t *testing.T) {
128+
t.Parallel()
129+
client := coderdtest.New(t, &coderdtest.Options{})
130+
_ = coderdtest.CreateFirstUser(t, client)
131+
132+
ctx := testutil.Context(t, testutil.WaitShort)
133+
134+
// Test with non-existent build IDs
135+
nonExistentID := uuid.New()
136+
experimentalClient := codersdk.NewExperimentalClient(client)
137+
prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{nonExistentID})
138+
require.NoError(t, err)
139+
require.Empty(t, prompts.Prompts)
140+
})
141+
}

coderd/apidoc/docs.go

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/autobuild/lifecycle_executor.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"database/sql"
66
"fmt"
77
"net/http"
8+
"slices"
9+
"strings"
810
"sync"
911
"sync/atomic"
1012
"time"
@@ -155,6 +157,22 @@ func (e *Executor) runOnce(t time.Time) Stats {
155157
return stats
156158
}
157159

160+
// Sort the workspaces by build template version ID so that we can group
161+
// identical template versions together. This is a slight (and imperfect)
162+
// optimization.
163+
//
164+
// `wsbuilder` needs to load the terraform files for a given template version
165+
// into memory. If 2 workspaces are using the same template version, they will
166+
// share the same files in the FileCache. This only happens if the builds happen
167+
// in parallel.
168+
// TODO: Actually make sure the cache has the files in the cache for the full
169+
// set of identical template versions. Then unload the files when the builds
170+
// are done. Right now, this relies on luck for the 10 goroutine workers to
171+
// overlap and keep the file reference in the cache alive.
172+
slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForTransitionRow) int {
173+
return strings.Compare(a.BuildTemplateVersionID.UUID.String(), b.BuildTemplateVersionID.UUID.String())
174+
})
175+
158176
// We only use errgroup here for convenience of API, not for early
159177
// cancellation. This means we only return nil errors in th eg.Go.
160178
eg := errgroup.Group{}

coderd/coderd.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,14 @@ func New(options *Options) *API {
939939
})
940940
})
941941

942+
// Experimental routes are not guaranteed to be stable and may change at any time.
943+
r.Route("/api/experimental", func(r chi.Router) {
944+
r.Use(apiKeyMiddleware)
945+
r.Route("/aitasks", func(r chi.Router) {
946+
r.Get("/prompts", api.aiTasksPrompts)
947+
})
948+
})
949+
942950
r.Route("/api/v2", func(r chi.Router) {
943951
api.APIHandler = r
944952

@@ -1536,7 +1544,6 @@ func New(options *Options) *API {
15361544
// Add CSP headers to all static assets and pages. CSP headers only affect
15371545
// browsers, so these don't make sense on api routes.
15381546
cspMW := httpmw.CSPHeaders(
1539-
api.Experiments,
15401547
options.Telemetry.Enabled(), func() []*proxyhealth.ProxyHost {
15411548
if api.DeploymentValues.Dangerous.AllowAllCors {
15421549
// In this mode, allow all external requests.

0 commit comments

Comments
 (0)