mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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)
|
||||
|
||||
@@ -2665,6 +2665,91 @@ func (api *API) putChatDesktopEnabled(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
|
||||
func (api *API) getChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
raw, err := api.Database.GetChatWorkspaceTTL(ctx)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace TTL setting.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Validate/default the stored value so callers always receive a
|
||||
// well-formed duration string.
|
||||
d, err := codersdk.ParseChatWorkspaceTTL(raw)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Stored workspace TTL is invalid.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatWorkspaceTTLResponse{
|
||||
WorkspaceTTLMillis: d.Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.UpdateChatWorkspaceTTLRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate before converting to avoid int64 overflow in the
|
||||
// multiplication by time.Millisecond.
|
||||
if req.WorkspaceTTLMillis < 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Workspace TTL must be non-negative.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert milliseconds to duration.
|
||||
d := time.Duration(req.WorkspaceTTLMillis) * time.Millisecond
|
||||
|
||||
// Technically a duplication of validWorkspaceTTL but this is not scoped to templates.
|
||||
if d > 0 && d < ttlMinimum {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Workspace TTL must not be less than 1 minute.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if d > ttlMaximum {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Workspace TTL must not exceed 30 days.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store the canonicalized duration string.
|
||||
if err := api.Database.UpsertChatWorkspaceTTL(ctx, d.String()); httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
} else if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error updating workspace TTL setting.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
|
||||
|
||||
@@ -4775,6 +4775,94 @@ func TestChatDesktopEnabled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatWorkspaceTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
adminClient := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
||||
anonClient := codersdk.New(adminClient.URL)
|
||||
|
||||
// Default value is 0 (disabled) when nothing has been configured.
|
||||
resp, err := adminClient.GetChatWorkspaceTTL(ctx)
|
||||
require.NoError(t, err, "get default")
|
||||
require.Equal(t, int64(0), resp.WorkspaceTTLMillis, "default should be 0")
|
||||
|
||||
// Admin can set a positive TTL (2h = 7_200_000 ms).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 7_200_000,
|
||||
})
|
||||
require.NoError(t, err, "admin set 2h")
|
||||
|
||||
resp, err = adminClient.GetChatWorkspaceTTL(ctx)
|
||||
require.NoError(t, err, "get after set")
|
||||
require.Equal(t, int64(7_200_000), resp.WorkspaceTTLMillis, "should return 7200000 ms (2h)")
|
||||
|
||||
// Non-admin can read the value.
|
||||
resp, err = memberClient.GetChatWorkspaceTTL(ctx)
|
||||
require.NoError(t, err, "member get")
|
||||
require.Equal(t, int64(7_200_000), resp.WorkspaceTTLMillis, "member should see same value")
|
||||
|
||||
// Admin can set back to zero (disabled / template default).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 0,
|
||||
})
|
||||
require.NoError(t, err, "admin set 0")
|
||||
|
||||
resp, err = adminClient.GetChatWorkspaceTTL(ctx)
|
||||
require.NoError(t, err, "get after zero")
|
||||
require.Equal(t, int64(0), resp.WorkspaceTTLMillis, "should be 0 after reset")
|
||||
|
||||
// Non-admin write is forbidden.
|
||||
err = memberClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 3_600_000,
|
||||
})
|
||||
requireSDKError(t, err, http.StatusForbidden)
|
||||
|
||||
// Unauthenticated read is rejected.
|
||||
_, err = anonClient.GetChatWorkspaceTTL(ctx)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr, "anon get")
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode(), "anon should get 401")
|
||||
|
||||
// Validation: negative duration.
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: -3_600_000,
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
|
||||
// Validation: less than 1 minute (30s = 30_000 ms).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 30_000,
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
|
||||
// Boundary: just under 1 minute should be rejected (59_999 ms).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 59_999,
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
|
||||
// Boundary: exactly 1 minute should succeed (60_000 ms).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 60_000,
|
||||
})
|
||||
require.NoError(t, err, "exactly 1 minute should be accepted")
|
||||
|
||||
// Boundary: exactly 30 days should succeed (720h = 2_592_000_000 ms).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 2_592_000_000,
|
||||
})
|
||||
require.NoError(t, err, "720h (exactly 30 days) should be accepted")
|
||||
|
||||
// Validation: exceeds 30-day maximum (721h = 2_595_600_000 ms).
|
||||
err = adminClient.UpdateChatWorkspaceTTL(ctx, codersdk.UpdateChatWorkspaceTTLRequest{
|
||||
WorkspaceTTLMillis: 2_595_600_000,
|
||||
})
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1177,6 +1177,8 @@ func New(options *Options) *API {
|
||||
r.Put("/desktop-enabled", api.putChatDesktopEnabled)
|
||||
r.Get("/user-prompt", api.getUserChatCustomPrompt)
|
||||
r.Put("/user-prompt", api.putUserChatCustomPrompt)
|
||||
r.Get("/workspace-ttl", api.getChatWorkspaceTTL)
|
||||
r.Put("/workspace-ttl", api.putChatWorkspaceTTL)
|
||||
})
|
||||
// TODO(cian): place under /api/experimental/chats/config
|
||||
r.Route("/providers", func(r chi.Router) {
|
||||
|
||||
@@ -2676,6 +2676,16 @@ func (q *querier) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid
|
||||
return q.db.GetChatUsageLimitUserOverride(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatWorkspaceTTL(ctx context.Context) (string, error) {
|
||||
// The workspace-TTL setting is a deployment-wide value read by any
|
||||
// authenticated chat user. We only require that an explicit actor is
|
||||
// present in the context so unauthenticated calls fail closed.
|
||||
if _, ok := ActorFromContext(ctx); !ok {
|
||||
return "", ErrNoActor
|
||||
}
|
||||
return q.db.GetChatWorkspaceTTL(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
|
||||
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type)
|
||||
if err != nil {
|
||||
@@ -6765,6 +6775,14 @@ func (q *querier) UpsertChatUsageLimitUserOverride(ctx context.Context, arg data
|
||||
return q.db.UpsertChatUsageLimitUserOverride(ctx, arg)
|
||||
}
|
||||
|
||||
//nolint:revive // Parameter name matches the generated querier interface.
|
||||
func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpsertChatWorkspaceTTL(ctx, workspaceTtl)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
|
||||
return database.ConnectionLog{}, err
|
||||
|
||||
@@ -656,6 +656,10 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().GetChatDesktopEnabled(gomock.Any()).Return(false, nil).AnyTimes()
|
||||
check.Args().Asserts()
|
||||
}))
|
||||
s.Run("GetChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetChatWorkspaceTTL(gomock.Any()).Return("1h", nil).AnyTimes()
|
||||
check.Args().Asserts()
|
||||
}))
|
||||
s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
|
||||
configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
|
||||
@@ -869,6 +873,10 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().UpsertChatDesktopEnabled(gomock.Any(), false).Return(nil).AnyTimes()
|
||||
check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpsertChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().UpsertChatWorkspaceTTL(gomock.Any(), "1h").Return(nil).AnyTimes()
|
||||
check.Args("1h").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetUserChatSpendInPeriod", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetUserChatSpendInPeriodParams{
|
||||
UserID: uuid.New(),
|
||||
|
||||
@@ -1216,6 +1216,14 @@ func (m queryMetricsStore) GetChatUsageLimitUserOverride(ctx context.Context, us
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatWorkspaceTTL(ctx context.Context) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatWorkspaceTTL(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetChatWorkspaceTTL").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatWorkspaceTTL").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChats(ctx, arg)
|
||||
@@ -4768,6 +4776,14 @@ func (m queryMetricsStore) UpsertChatUsageLimitUserOverride(ctx context.Context,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertChatWorkspaceTTL(ctx, workspaceTtl)
|
||||
m.queryLatencies.WithLabelValues("UpsertChatWorkspaceTTL").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatWorkspaceTTL").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertConnectionLog(ctx, arg)
|
||||
|
||||
@@ -2224,6 +2224,21 @@ func (mr *MockStoreMockRecorder) GetChatUsageLimitUserOverride(ctx, userID any)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatUsageLimitUserOverride", reflect.TypeOf((*MockStore)(nil).GetChatUsageLimitUserOverride), ctx, userID)
|
||||
}
|
||||
|
||||
// GetChatWorkspaceTTL mocks base method.
|
||||
func (m *MockStore) GetChatWorkspaceTTL(ctx context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChatWorkspaceTTL", ctx)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChatWorkspaceTTL indicates an expected call of GetChatWorkspaceTTL.
|
||||
func (mr *MockStoreMockRecorder) GetChatWorkspaceTTL(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).GetChatWorkspaceTTL), ctx)
|
||||
}
|
||||
|
||||
// GetChats mocks base method.
|
||||
func (m *MockStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -8909,6 +8924,20 @@ func (mr *MockStoreMockRecorder) UpsertChatUsageLimitUserOverride(ctx, arg any)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatUsageLimitUserOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatUsageLimitUserOverride), ctx, arg)
|
||||
}
|
||||
|
||||
// UpsertChatWorkspaceTTL mocks base method.
|
||||
func (m *MockStore) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertChatWorkspaceTTL", ctx, workspaceTtl)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpsertChatWorkspaceTTL indicates an expected call of UpsertChatWorkspaceTTL.
|
||||
func (mr *MockStoreMockRecorder) UpsertChatWorkspaceTTL(ctx, workspaceTtl any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpsertChatWorkspaceTTL), ctx, workspaceTtl)
|
||||
}
|
||||
|
||||
// UpsertConnectionLog mocks base method.
|
||||
func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -255,6 +255,9 @@ type sqlcQuerier interface {
|
||||
GetChatUsageLimitConfig(ctx context.Context) (ChatUsageLimitConfig, error)
|
||||
GetChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) (GetChatUsageLimitGroupOverrideRow, error)
|
||||
GetChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) (GetChatUsageLimitUserOverrideRow, error)
|
||||
// Returns the global TTL for chat workspaces as a Go duration string.
|
||||
// Returns "0s" (disabled) when no value has been configured.
|
||||
GetChatWorkspaceTTL(ctx context.Context) (string, error)
|
||||
GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, error)
|
||||
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
|
||||
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
|
||||
@@ -923,6 +926,7 @@ type sqlcQuerier interface {
|
||||
UpsertChatUsageLimitConfig(ctx context.Context, arg UpsertChatUsageLimitConfigParams) (ChatUsageLimitConfig, error)
|
||||
UpsertChatUsageLimitGroupOverride(ctx context.Context, arg UpsertChatUsageLimitGroupOverrideParams) (UpsertChatUsageLimitGroupOverrideRow, error)
|
||||
UpsertChatUsageLimitUserOverride(ctx context.Context, arg UpsertChatUsageLimitUserOverrideParams) (UpsertChatUsageLimitUserOverrideRow, error)
|
||||
UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error
|
||||
UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error)
|
||||
// The default proxy is implied and not actually stored in the database.
|
||||
// So we need to store it's configuration here for display purposes.
|
||||
|
||||
@@ -16828,6 +16828,23 @@ func (q *sqlQuerier) GetChatSystemPrompt(ctx context.Context) (string, error) {
|
||||
return chat_system_prompt, err
|
||||
}
|
||||
|
||||
const getChatWorkspaceTTL = `-- name: GetChatWorkspaceTTL :one
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT value FROM site_configs WHERE key = 'agents_workspace_ttl'),
|
||||
'0s'
|
||||
)::text AS workspace_ttl
|
||||
`
|
||||
|
||||
// Returns the global TTL for chat workspaces as a Go duration string.
|
||||
// Returns "0s" (disabled) when no value has been configured.
|
||||
func (q *sqlQuerier) GetChatWorkspaceTTL(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatWorkspaceTTL)
|
||||
var workspace_ttl string
|
||||
err := row.Scan(&workspace_ttl)
|
||||
return workspace_ttl, err
|
||||
}
|
||||
|
||||
const getDERPMeshKey = `-- name: GetDERPMeshKey :one
|
||||
SELECT value FROM site_configs WHERE key = 'derp_mesh_key'
|
||||
`
|
||||
@@ -17042,6 +17059,19 @@ func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) e
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatWorkspaceTTL = `-- name: UpsertChatWorkspaceTTL :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES ('agents_workspace_ttl', $1::text)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = $1::text
|
||||
WHERE site_configs.key = 'agents_workspace_ttl'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertChatWorkspaceTTL, workspaceTtl)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertDefaultProxy = `-- name: UpsertDefaultProxy :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES
|
||||
|
||||
@@ -160,3 +160,19 @@ SET value = CASE
|
||||
ELSE 'false'
|
||||
END
|
||||
WHERE site_configs.key = 'agents_desktop_enabled';
|
||||
|
||||
-- name: GetChatWorkspaceTTL :one
|
||||
-- Returns the global TTL for chat workspaces as a Go duration string.
|
||||
-- Returns "0s" (disabled) when no value has been configured.
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT value FROM site_configs WHERE key = 'agents_workspace_ttl'),
|
||||
'0s'
|
||||
)::text AS workspace_ttl;
|
||||
|
||||
-- name: UpsertChatWorkspaceTTL :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES ('agents_workspace_ttl', @workspace_ttl::text)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = @workspace_ttl::text
|
||||
WHERE site_configs.key = 'agents_workspace_ttl';
|
||||
|
||||
@@ -359,6 +359,42 @@ type UpdateChatDesktopEnabledRequest struct {
|
||||
EnableDesktop bool `json:"enable_desktop"`
|
||||
}
|
||||
|
||||
// DefaultChatWorkspaceTTL is the default TTL for chat workspaces.
|
||||
// Zero means disabled — the template's own autostop setting applies.
|
||||
const DefaultChatWorkspaceTTL = 0
|
||||
|
||||
// ChatWorkspaceTTLResponse is the response for getting the chat
|
||||
// workspace TTL setting.
|
||||
type ChatWorkspaceTTLResponse struct {
|
||||
// WorkspaceTTLMillis is the workspace TTL in milliseconds.
|
||||
// Zero means disabled — the template's own autostop setting applies.
|
||||
WorkspaceTTLMillis int64 `json:"workspace_ttl_ms"`
|
||||
}
|
||||
|
||||
// UpdateChatWorkspaceTTLRequest is the request to update the chat
|
||||
// workspace TTL setting.
|
||||
type UpdateChatWorkspaceTTLRequest struct {
|
||||
// WorkspaceTTLMillis is the workspace TTL in milliseconds.
|
||||
// Zero means disabled — the template's own autostop setting applies.
|
||||
WorkspaceTTLMillis int64 `json:"workspace_ttl_ms"`
|
||||
}
|
||||
|
||||
// ParseChatWorkspaceTTL parses a stored TTL string, returning the
|
||||
// default when the value is empty.
|
||||
func ParseChatWorkspaceTTL(s string) (time.Duration, error) {
|
||||
if s == "" {
|
||||
return DefaultChatWorkspaceTTL, nil
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, xerrors.New("duration must be non-negative")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ChatProviderConfigSource describes how a provider entry is sourced.
|
||||
type ChatProviderConfigSource string
|
||||
|
||||
@@ -1336,6 +1372,33 @@ func (c *Client) UpdateChatDesktopEnabled(ctx context.Context, req UpdateChatDes
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChatWorkspaceTTL returns the configured chat workspace TTL.
|
||||
func (c *Client) GetChatWorkspaceTTL(ctx context.Context) (ChatWorkspaceTTLResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/workspace-ttl", nil)
|
||||
if err != nil {
|
||||
return ChatWorkspaceTTLResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ChatWorkspaceTTLResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp ChatWorkspaceTTLResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpdateChatWorkspaceTTL updates the chat workspace TTL setting.
|
||||
func (c *Client) UpdateChatWorkspaceTTL(ctx context.Context, req UpdateChatWorkspaceTTLRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/workspace-ttl", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserChatCustomPrompt updates the user's custom chat prompt.
|
||||
func (c *Client) UpdateUserChatCustomPrompt(ctx context.Context, req UserChatCustomPrompt) (UserChatCustomPrompt, error) {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/user-prompt", req)
|
||||
|
||||
@@ -362,3 +362,35 @@ func TestChatCostSummary_JSONRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, original.TotalCostMicros, decoded.TotalCostMicros)
|
||||
}
|
||||
|
||||
//nolint:tparallel,paralleltest
|
||||
func TestParseChatWorkspaceTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"Empty_ReturnsDefault", "", 0, false},
|
||||
{"ValidDuration_Hours", "2h", 2 * time.Hour, false},
|
||||
{"ValidDuration_HoursAndMinutes", "2h30m", 2*time.Hour + 30*time.Minute, false},
|
||||
{"ValidDuration_Minutes", "90m", 90 * time.Minute, false},
|
||||
{"Zero", "0s", 0, false},
|
||||
{"Negative", "-1h", 0, true},
|
||||
{"Invalid", "not-a-duration", 0, true},
|
||||
{"LargeDuration", "720h", 720 * time.Hour, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := codersdk.ParseChatWorkspaceTTL(tc.input)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3138,6 +3138,20 @@ class ApiMethods {
|
||||
await this.axios.put("/api/experimental/chats/config/desktop-enabled", req);
|
||||
};
|
||||
|
||||
getChatWorkspaceTTL =
|
||||
async (): Promise<TypesGen.ChatWorkspaceTTLResponse> => {
|
||||
const response = await this.axios.get<TypesGen.ChatWorkspaceTTLResponse>(
|
||||
"/api/experimental/chats/config/workspace-ttl",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatWorkspaceTTL = async (
|
||||
req: TypesGen.UpdateChatWorkspaceTTLRequest,
|
||||
): Promise<void> => {
|
||||
await this.axios.put("/api/experimental/chats/config/workspace-ttl", req);
|
||||
};
|
||||
|
||||
getUserChatCustomPrompt =
|
||||
async (): Promise<TypesGen.UserChatCustomPrompt> => {
|
||||
const response = await this.axios.get<TypesGen.UserChatCustomPrompt>(
|
||||
|
||||
@@ -406,6 +406,22 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const chatWorkspaceTTLKey = ["chat-workspace-ttl"] as const;
|
||||
|
||||
export const chatWorkspaceTTL = () => ({
|
||||
queryKey: chatWorkspaceTTLKey,
|
||||
queryFn: () => API.getChatWorkspaceTTL(),
|
||||
});
|
||||
|
||||
export const updateChatWorkspaceTTL = (queryClient: QueryClient) => ({
|
||||
mutationFn: API.updateChatWorkspaceTTL,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatWorkspaceTTLKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const chatUserCustomPromptKey = ["chat-user-custom-prompt"] as const;
|
||||
|
||||
export const chatUserCustomPrompt = () => ({
|
||||
|
||||
Generated
+33
@@ -1941,6 +1941,19 @@ export interface ChatUsageLimitStatus {
|
||||
readonly period_end?: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatWorkspaceTTLResponse is the response for getting the chat
|
||||
* workspace TTL setting.
|
||||
*/
|
||||
export interface ChatWorkspaceTTLResponse {
|
||||
/**
|
||||
* WorkspaceTTLMillis is the workspace TTL in milliseconds.
|
||||
* Zero means disabled — the template's own autostop setting applies.
|
||||
*/
|
||||
readonly workspace_ttl_ms: number;
|
||||
}
|
||||
|
||||
// From codersdk/client.go
|
||||
/**
|
||||
* CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry
|
||||
@@ -2716,6 +2729,13 @@ export interface DebugProfileOptions {
|
||||
readonly Profiles: readonly string[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* DefaultChatWorkspaceTTL is the default TTL for chat workspaces.
|
||||
* Zero means disabled — the template's own autostop setting applies.
|
||||
*/
|
||||
export const DefaultChatWorkspaceTTL = 0;
|
||||
|
||||
// From codersdk/externalauth.go
|
||||
export interface DeleteExternalAuthByIDResponse {
|
||||
/**
|
||||
@@ -6884,6 +6904,19 @@ export interface UpdateChatUsageLimitOverrideRequest {
|
||||
readonly spend_limit_micros: number; // Must be greater than 0.
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* UpdateChatWorkspaceTTLRequest is the request to update the chat
|
||||
* workspace TTL setting.
|
||||
*/
|
||||
export interface UpdateChatWorkspaceTTLRequest {
|
||||
/**
|
||||
* WorkspaceTTLMillis is the workspace TTL in milliseconds.
|
||||
* Zero means disabled — the template's own autostop setting applies.
|
||||
*/
|
||||
readonly workspace_ttl_ms: number;
|
||||
}
|
||||
|
||||
// From codersdk/updatecheck.go
|
||||
/**
|
||||
* UpdateCheckResponse contains information on the latest release of Coder.
|
||||
|
||||
@@ -145,6 +145,10 @@ const meta = {
|
||||
spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({
|
||||
custom_prompt: "",
|
||||
});
|
||||
spyOn(API, "getChatWorkspaceTTL").mockResolvedValue({
|
||||
workspace_ttl_ms: 0,
|
||||
});
|
||||
spyOn(API, "updateChatWorkspaceTTL").mockResolvedValue();
|
||||
},
|
||||
} satisfies Meta<typeof SettingsPageContent>;
|
||||
|
||||
@@ -181,6 +185,114 @@ export const TogglesDesktop: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultAutostopDefault: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText("Default Autostop");
|
||||
// When disabled (0s), shows template-default copy.
|
||||
await canvas.findByText(/stopped as configured by their templates/i);
|
||||
|
||||
// DurationField renders a text input labeled "Default autostop".
|
||||
const durationInput = await canvas.findByLabelText("Default autostop");
|
||||
|
||||
// Default is "0s" → 0 hours (disabled).
|
||||
expect(durationInput).toHaveValue("0");
|
||||
|
||||
// Save button should be disabled (no local change).
|
||||
const ttlForm = durationInput.closest("form")!;
|
||||
const saveButton = within(ttlForm).getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultAutostopCustomValue: Story = {
|
||||
beforeEach: () => {
|
||||
// 2h = 2 hours exactly, shows cleanly in DurationField.
|
||||
spyOn(API, "getChatWorkspaceTTL").mockResolvedValue({
|
||||
workspace_ttl_ms: 7_200_000,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const durationInput = await canvas.findByLabelText("Default autostop");
|
||||
|
||||
// Shows 2 hours from the mock.
|
||||
expect(durationInput).toHaveValue("2");
|
||||
|
||||
// When non-zero, shows the duration in the description.
|
||||
await canvas.findByText(/stopped after 2 hours of inactivity/i);
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultAutostopSave: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const durationInput = await canvas.findByLabelText("Default autostop");
|
||||
const ttlForm = durationInput.closest("form")!;
|
||||
const saveButton = within(ttlForm).getByRole("button", { name: "Save" });
|
||||
|
||||
// Change to 3 hours.
|
||||
await userEvent.clear(durationInput);
|
||||
await userEvent.type(durationInput, "3");
|
||||
|
||||
// Save button should now be enabled.
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
await userEvent.click(saveButton);
|
||||
await waitFor(() => {
|
||||
expect(API.updateChatWorkspaceTTL).toHaveBeenCalledWith({
|
||||
workspace_ttl_ms: 10_800_000,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultAutostopExceedsMax: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const durationInput = await canvas.findByLabelText("Default autostop");
|
||||
const ttlForm = durationInput.closest("form")!;
|
||||
const saveButton = within(ttlForm).getByRole("button", { name: "Save" });
|
||||
|
||||
// Enter 721 hours (exceeds 30-day / 720h limit).
|
||||
await userEvent.clear(durationInput);
|
||||
await userEvent.type(durationInput, "721");
|
||||
|
||||
// Error helper text should appear.
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText(/must not exceed 30 days/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save button should be disabled despite the field being dirty.
|
||||
expect(saveButton).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const DefaultAutostopNotVisibleToNonAdmin: Story = {
|
||||
args: {
|
||||
canSetSystemPrompt: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Personal Instructions should be visible.
|
||||
await canvas.findByText("Personal Instructions");
|
||||
|
||||
// Admin-only sections should not be present.
|
||||
const ttlHeading = canvas.queryByText("Default Autostop");
|
||||
expect(ttlHeading).toBeNull();
|
||||
|
||||
const desktopHeading = canvas.queryByText("Virtual Desktop");
|
||||
expect(desktopHeading).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
// ── Usage tab stories ──────────────────────────────────────────
|
||||
|
||||
export const UsageUserList: Story = {
|
||||
|
||||
@@ -5,14 +5,17 @@ import {
|
||||
chatDesktopEnabled,
|
||||
chatSystemPrompt,
|
||||
chatUserCustomPrompt,
|
||||
chatWorkspaceTTL,
|
||||
updateChatDesktopEnabled,
|
||||
updateChatSystemPrompt,
|
||||
updateChatWorkspaceTTL,
|
||||
updateUserChatCustomPrompt,
|
||||
} from "api/queries/chats";
|
||||
import { userByName } from "api/queries/users";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { DurationField } from "components/DurationField/DurationField";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { PaginationAmount } from "components/PaginationWidget/PaginationAmount";
|
||||
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
|
||||
@@ -48,6 +51,7 @@ import { useSearchParams } from "react-router";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { formatTokenCount } from "utils/analytics";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { humanDuration } from "utils/time";
|
||||
import { ChatCostSummaryView } from "./ChatCostSummaryView";
|
||||
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
import { InsightsContent } from "./InsightsContent";
|
||||
@@ -430,6 +434,13 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
||||
isError: isSaveDesktopEnabledError,
|
||||
} = useMutation(updateChatDesktopEnabled(queryClient));
|
||||
|
||||
const workspaceTTLQuery = useQuery(chatWorkspaceTTL());
|
||||
const {
|
||||
mutate: saveWorkspaceTTL,
|
||||
isPending: isSavingWorkspaceTTL,
|
||||
isError: isSaveWorkspaceTTLError,
|
||||
} = useMutation(updateChatWorkspaceTTL(queryClient));
|
||||
|
||||
const serverPrompt = systemPromptQuery.data?.system_prompt ?? "";
|
||||
const [localEdit, setLocalEdit] = useState<string | null>(null);
|
||||
const systemPromptDraft = localEdit ?? serverPrompt;
|
||||
@@ -442,8 +453,18 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
||||
const isUserPromptDirty =
|
||||
localUserEdit !== null && localUserEdit !== serverUserPrompt;
|
||||
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
|
||||
const serverTTLMs = workspaceTTLQuery.data?.workspace_ttl_ms ?? 0;
|
||||
const [localTTLMs, setLocalTTLMs] = useState<number | null>(null);
|
||||
const ttlMs = localTTLMs ?? serverTTLMs;
|
||||
const isTTLDirty = localTTLMs !== null && localTTLMs !== serverTTLMs;
|
||||
const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days
|
||||
const isTTLOverMax = ttlMs > maxTTLMs;
|
||||
const isDisabled =
|
||||
isSavingSystemPrompt || isSavingUserPrompt || isSavingDesktopEnabled;
|
||||
isSavingSystemPrompt ||
|
||||
isSavingUserPrompt ||
|
||||
isSavingDesktopEnabled ||
|
||||
isSavingWorkspaceTTL;
|
||||
const isTTLLoading = workspaceTTLQuery.isLoading;
|
||||
|
||||
const handleSaveSystemPrompt = useCallback(
|
||||
(event: FormEvent) => {
|
||||
@@ -469,6 +490,18 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
||||
[isUserPromptDirty, userPromptDraft, saveUserPrompt],
|
||||
);
|
||||
|
||||
const handleSaveChatWorkspaceTTL = useCallback(
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isTTLDirty) return;
|
||||
saveWorkspaceTTL(
|
||||
{ workspace_ttl_ms: localTTLMs ?? 0 },
|
||||
{ onSuccess: () => setLocalTTLMs(null) },
|
||||
);
|
||||
},
|
||||
[isTTLDirty, localTTLMs, saveWorkspaceTTL],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 pt-8 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
@@ -615,6 +648,54 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<hr className="my-5 border-0 border-t border-solid border-border" />
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => void handleSaveChatWorkspaceTTL(event)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
Default Autostop
|
||||
</h3>
|
||||
<AdminBadge />
|
||||
</div>
|
||||
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
|
||||
{ttlMs === 0
|
||||
? "Workspaces linked to chats will be stopped as configured by their templates. Active chats continuously extend the deadline."
|
||||
: `Workspaces linked to chats will be stopped after ${humanDuration(ttlMs)} of inactivity. Active chats continuously extend the deadline.`}
|
||||
</p>
|
||||
<DurationField
|
||||
label="Default autostop"
|
||||
valueMs={ttlMs}
|
||||
onChange={(v) => setLocalTTLMs(v)}
|
||||
disabled={isDisabled || isTTLLoading}
|
||||
error={isTTLOverMax}
|
||||
helperText={
|
||||
isTTLOverMax
|
||||
? "Must not exceed 30 days (720 hours)."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isDisabled || !isTTLDirty || isTTLOverMax}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{isSaveWorkspaceTTLError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to save autostop setting.
|
||||
</p>
|
||||
)}
|
||||
{workspaceTTLQuery.isError && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
Failed to load autostop setting.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user