mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add global chat workspace TTL setting (#23265)
- Add `agents_workspace_ttl` site config (default: whatever the template says a.k.a. `0s`) - Expose via GET/PUT `/api/experimental/chats/config/workspace-ttl` - Chat tool reads setting and passes `TTLMillis` on workspace creation - Existing autostop infrastructure handles the rest (zero changes to LifecycleExecutor, CalculateAutostop, or activity bumping) - ⚠️ Template-level `UserAutostopEnabled=false` overrides this global default. Not touching this. - Frontend: "Workspace Lifetime" control in /agents/settings Behavior tab (admin-only) > This PR was created with the help of Coder Agents, and has been reviewed by several humans and robots. 🤖🤝🧑💻
This commit is contained in:
@@ -140,8 +140,30 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool {
|
||||
ctx = ownerCtx
|
||||
}
|
||||
|
||||
var ttlMs *int64
|
||||
if options.DB != nil {
|
||||
raw, err := options.DB.GetChatWorkspaceTTL(ctx)
|
||||
if err != nil {
|
||||
options.Logger.Error(ctx, "failed to read chat workspace TTL setting, using template default",
|
||||
slog.Error(err),
|
||||
)
|
||||
} else {
|
||||
d, parseErr := codersdk.ParseChatWorkspaceTTL(raw)
|
||||
if parseErr != nil {
|
||||
options.Logger.Warn(ctx, "invalid chat workspace TTL setting, using template default",
|
||||
slog.F("raw", raw),
|
||||
slog.Error(parseErr),
|
||||
)
|
||||
} else if d > 0 {
|
||||
ms := d.Milliseconds()
|
||||
ttlMs = &ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createReq := codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: templateID,
|
||||
TTLMillis: ttlMs,
|
||||
}
|
||||
|
||||
// Resolve workspace name.
|
||||
|
||||
@@ -2,14 +2,22 @@ package chattool //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
@@ -109,6 +117,117 @@ func TestWaitForAgentReady(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateWorkspace_GlobalTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ttlReturn string
|
||||
ttlErr error
|
||||
wantTTLMs *int64
|
||||
}{
|
||||
{
|
||||
name: "PositiveTTL",
|
||||
ttlReturn: "2h",
|
||||
wantTTLMs: ptr.Ref(int64(2 * time.Hour / time.Millisecond)),
|
||||
},
|
||||
{
|
||||
name: "ZeroTTLUsesTemplateDefault",
|
||||
ttlReturn: "0s",
|
||||
wantTTLMs: nil,
|
||||
},
|
||||
{
|
||||
name: "DBError_FallsBackToNil",
|
||||
ttlReturn: "",
|
||||
ttlErr: xerrors.New("db error"),
|
||||
wantTTLMs: nil,
|
||||
},
|
||||
{
|
||||
name: "InvalidStoredValue_FallsBackToNil",
|
||||
ttlReturn: "not-a-duration",
|
||||
wantTTLMs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(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()
|
||||
|
||||
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(tc.ttlReturn, tc.ttlErr)
|
||||
|
||||
db.EXPECT().
|
||||
GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), workspaceID).
|
||||
Return(database.WorkspaceBuild{
|
||||
WorkspaceID: workspaceID,
|
||||
JobID: jobID,
|
||||
}, nil)
|
||||
db.EXPECT().
|
||||
GetProvisionerJobByID(gomock.Any(), jobID).
|
||||
Return(database.ProvisionerJob{
|
||||
ID: jobID,
|
||||
JobStatus: database.ProvisionerJobStatusSucceeded,
|
||||
}, nil)
|
||||
|
||||
db.EXPECT().
|
||||
GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID).
|
||||
Return([]database.WorkspaceAgent{}, nil)
|
||||
|
||||
var capturedReq codersdk.CreateWorkspaceRequest
|
||||
createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
|
||||
capturedReq = req
|
||||
return codersdk.Workspace{
|
||||
ID: workspaceID,
|
||||
Name: req.Name,
|
||||
OwnerName: "testuser",
|
||||
}, 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-ws-%s"}`, templateID.String(), tc.name)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "create_workspace",
|
||||
Input: input,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, resp.Content)
|
||||
|
||||
if tc.wantTTLMs != nil {
|
||||
require.NotNil(t, capturedReq.TTLMillis)
|
||||
require.Equal(t, *tc.wantTTLMs, *capturedReq.TTLMillis)
|
||||
} else {
|
||||
require.Nil(t, capturedReq.TTLMillis)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExistingWorkspace_DeletedWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
Reference in New Issue
Block a user