mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Removes the experimental Chat Automations feature (`chat-automations` experiment) from main. This reverts the 16-PR stack that added it and adds two forward migrations that undo its schema and credential changes.
> [!WARNING]
> **Upgrade consequences**
>
> - **Automation data is deleted.** Migration 000615 drops the `chat_automations` table and deletes every queued message an automation delivered. Chats, their messages and ordinary queued messages stay. Messages lose their automation provenance (`automation_id`, `input_id`).
> - **Automation-only credentials are revoked.** Migration 000616 deletes API keys, OAuth2 codes and OAuth2 tokens whose only scopes (or allow-list entries) were `chat_automation:*`. Mixed credentials keep their other scopes. Custom roles lose their `chat_automation` permissions. OAuth2 apps that allowed only `chat_automation` scopes get the whitespace allowlist that grants nothing, as in 000595.
> - **A coordinated restart is required.** Stop all old replicas before the new version runs the migrations. Old replicas read the dropped table and columns, so a rolling deploy is not supported.
> - **Remove `--chat-max-automations-per-owner` before upgrading.** The flag, the YAML key `chat.maxAutomationsPerOwner` and the env var `CODER_CHAT_MAX_AUTOMATIONS_PER_OWNER` are removed. A deployment that still sets the flag or the YAML key will not start (`unknown flag` / `unknown option`). The env var is ignored. This includes dogfood. No release or tag contains the flag.
> - **Down migrations restore the schema only.** 000616 down and 000615 down recreate the 000611 schema with empty tables. They do not restore deleted automations, webhook secrets, provenance, queued deliveries, revoked credentials, stripped permissions or the deleted experiment rule.
## What is removed
- The `chat-automations` experiment and its stored runtime rule (`experiment_rule:chat-automations`).
- The `--chat-max-automations-per-owner` deployment option.
- The `chat_automation` RBAC resource, its member and chatd grants, and the `chat_automation:*` API key scopes.
- The automation sqlc queries, dbauthz wrappers and audit table entries.
- The management routes under `/api/experimental/organizations/{organization}/chat-automations`, and the webhook route `POST /api/experimental/chat-automations/{automation}/events` with its rate limiter.
- The chatd management service, webhook publish, schedule loop, the `manage_automations` tool, and the chatstate admission seam and queue promotion guard that only automations used.
- The Agents Automations page, editor, nav entry, chat labels, the Manage automations toggle and the API client.
- The `archived:any` chat search value that the automations "View chats" dialog added.
- `docs/ai-coder/agents/automations.md`, its manifest entry, the glossary entry and the related rows.
Reverted PRs: #30085, #30096, #30112, #30117, #30127, #30134, #30160, #30166, #30167, #30171, #30187, #30207, #30214, #30217, #30219, #30220.
## What stays, and why
- Migrations 000609, 000610 and 000611, their fixture, and the #30297 lock fix with `migration000609_test.go` stay unchanged. Deployments such as dogfood already ran them.
- New migration **000615** reverses the schema changes of 000609 and 000611 against the current schema. It uses the same short-wait lock-and-retry block as 000609, and it rebuilds `chats_expanded` from main's current definition minus the two automation columns. New migration **000616** follows the 000595 Tasks removal for credentials and recreates `api_key_scope` without the five `chat_automation:*` values. It keeps the `chat_project_memory:*` values from 000612.
- The `chat_automation` value stays in the `resource_type` enum, and `codersdk.ResourceTypeChatAutomation` stays. Old audit log rows stay readable, as with `task` after the Tasks removal.
- The 000615 delete fires the existing queue-version trigger, so `queue_version` moves to `snapshot_version` for each affected chat. `snapshot_version` is not bumped: the coordinated restart restarts every stream loop from an empty state, and the site does not read the version fields.
- `coderd/x/chatd/ARCHITECTURE.md` drops the sections the stack added and gets back its pre-stack wording, merged with the later main edits to the same file.
## Notes for reviewers
- #30379 (chat project page) merged while this PR was in progress. It passed `manageAutomationsEnabled` in `AgentCreatePage.test.tsx` and listed an `automations` route in `AgentsPageLayout.test.tsx`. Later main commits also added automation props in `ConversationTimeline.test.tsx` and `ConversationTimelineWorkingBlocks.stories.tsx`. This PR removes all of them.
- Remaining `automation` matches under `coderd`, `codersdk`, `enterprise`, `site/src` and `docs` are intended:
- migrations 000609 to 000611, their fixture and `migration000609_test.go`;
- the new 000615 and 000616 migrations and `migration000615_test.go`;
- the `chat_automation` resource type in `dump.sql`, `models.go`, `codersdk/audit.go`, the generated API docs, `swagger.json` and `typesGenerated.ts`;
- unrelated uses of the word "automation" (docs, `chatlabels_test.go`, `ratelimit.go`, `autostop.go`, `subagent_catalog.go`, `WorkspaceSharingSection.tsx`, `AgentChatPage.stories.tsx`).
Remote UAT on an isolated dev deployment passed. It upgraded a populated pre-removal database, then checked chats, queued messages, tokens, the 404 routes, the removed page and nav entry, and the experiments list.
---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_

301 lines
13 KiB
Go
301 lines
13 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// ruleWriter writes experiment rules through the experimental API and
|
|
// tracks the revision of each experiment so that every write expects the
|
|
// current revision.
|
|
type ruleWriter struct {
|
|
t *testing.T
|
|
client *codersdk.ExperimentalClient
|
|
revisions map[codersdk.Experiment]int64
|
|
}
|
|
|
|
func newRuleWriter(t *testing.T, client *codersdk.Client) *ruleWriter {
|
|
return &ruleWriter{t: t, client: codersdk.NewExperimentalClient(client), revisions: map[codersdk.Experiment]int64{}}
|
|
}
|
|
|
|
func (w *ruleWriter) put(ctx context.Context, ex codersdk.Experiment, mode codersdk.ExperimentRuleMode, condition string) codersdk.ExperimentRule {
|
|
w.t.Helper()
|
|
rule, err := w.client.PutExperimentRule(ctx, ex, codersdk.PutExperimentRuleRequest{
|
|
Mode: mode,
|
|
Condition: condition,
|
|
ExpectedRevision: w.revisions[ex],
|
|
})
|
|
require.NoError(w.t, err)
|
|
require.Equal(w.t, w.revisions[ex]+1, rule.Revision)
|
|
w.revisions[ex] = rule.Revision
|
|
return rule
|
|
}
|
|
|
|
func requireEnabled(ctx context.Context, t *testing.T, client *codersdk.Client, ex codersdk.Experiment, want bool) {
|
|
t.Helper()
|
|
got, err := client.Experiments(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, want, got.Enabled(ex), "experiment %q for the caller", ex)
|
|
}
|
|
|
|
func requireStatus(t *testing.T, err error, status int) *codersdk.Error {
|
|
t.Helper()
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, status, sdkErr.StatusCode(), sdkErr.Error())
|
|
return sdkErr
|
|
}
|
|
|
|
func ruleEntry(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, ex codersdk.Experiment) codersdk.ExperimentRuleEntry {
|
|
t.Helper()
|
|
entries, err := client.ExperimentRules(ctx)
|
|
require.NoError(t, err)
|
|
for _, entry := range entries {
|
|
if entry.Experiment == string(ex) {
|
|
return entry
|
|
}
|
|
}
|
|
t.Fatalf("no rules entry for experiment %q", ex)
|
|
return codersdk.ExperimentRuleEntry{}
|
|
}
|
|
|
|
func TestExperimentRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each write takes effect on the member's next request, without a
|
|
// restart. mcp-tool-search is enabled at startup, example is not.
|
|
t.Run("WritesApplyToNextRequest", func(t *testing.T) {
|
|
t.Parallel()
|
|
ownerClient := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: coderdtest.DeploymentValues(t, func(v *codersdk.DeploymentValues) {
|
|
v.Experiments = []string{string(codersdk.ExperimentMCPToolSearch)}
|
|
}),
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
w := newRuleWriter(t, ownerClient)
|
|
|
|
entries, err := w.client.ExperimentRules(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []codersdk.ExperimentRuleEntry{
|
|
{Experiment: string(codersdk.ExperimentExample)},
|
|
{Experiment: string(codersdk.ExperimentMCPToolSearch), StaticDefault: true},
|
|
}, entries)
|
|
|
|
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOn, "")
|
|
requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)
|
|
|
|
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.username == %q", member.Username))
|
|
requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)
|
|
requireEnabled(ctx, t, ownerClient, codersdk.ExperimentExample, false)
|
|
|
|
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOff, "")
|
|
requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, false)
|
|
|
|
// off is a kill switch for a statically enabled experiment, and
|
|
// reset restores the startup default.
|
|
w.put(ctx, codersdk.ExperimentMCPToolSearch, codersdk.ExperimentRuleModeOff, "")
|
|
requireEnabled(ctx, t, memberClient, codersdk.ExperimentMCPToolSearch, false)
|
|
w.put(ctx, codersdk.ExperimentMCPToolSearch, codersdk.ExperimentRuleModeInherit, "")
|
|
requireEnabled(ctx, t, memberClient, codersdk.ExperimentMCPToolSearch, true)
|
|
|
|
entry := ruleEntry(ctx, t, w.client, codersdk.ExperimentMCPToolSearch)
|
|
require.True(t, entry.StaticDefault)
|
|
require.NotNil(t, entry.Rule)
|
|
require.Equal(t, string(codersdk.ExperimentRuleModeInherit), entry.Rule.Mode)
|
|
require.Equal(t, int64(2), entry.Rule.Revision)
|
|
require.Equal(t, owner.UserID, entry.Rule.UpdatedBy)
|
|
require.False(t, entry.Rule.UpdatedAt.IsZero())
|
|
|
|
// Responses carry condition text and must never be cached.
|
|
for _, req := range []struct {
|
|
method, path string
|
|
body any
|
|
}{
|
|
{http.MethodGet, "/api/experimental/experiments/rules", nil},
|
|
{http.MethodPut, "/api/experimental/experiments/rules/example", codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOff, ExpectedRevision: 3}},
|
|
} {
|
|
res, err := ownerClient.Request(ctx, req.method, req.path, req.body)
|
|
require.NoError(t, err)
|
|
_ = res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode, req.method)
|
|
require.Equal(t, "no-store", res.Header.Get("Cache-Control"), req.method)
|
|
}
|
|
})
|
|
|
|
t.Run("RejectedWritesKeepRule", func(t *testing.T) {
|
|
t.Parallel()
|
|
ownerClient := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, ownerClient)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
w := newRuleWriter(t, ownerClient)
|
|
stored := w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOn, "")
|
|
|
|
put := func(ex codersdk.Experiment, req codersdk.PutExperimentRuleRequest) error {
|
|
_, err := w.client.PutExperimentRule(ctx, ex, req)
|
|
return err
|
|
}
|
|
|
|
// The author of an invalid condition receives the diagnostics.
|
|
err := put(codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeCondition, Condition: `user.username == "a" &&`, ExpectedRevision: 1})
|
|
require.Contains(t, requireStatus(t, err, http.StatusBadRequest).Detail, "Syntax error")
|
|
|
|
for _, req := range []codersdk.PutExperimentRuleRequest{
|
|
{Mode: codersdk.ExperimentRuleModeOff, Condition: "true", ExpectedRevision: 1},
|
|
{Mode: codersdk.ExperimentRuleModeCondition, ExpectedRevision: 1},
|
|
{Mode: "percent", ExpectedRevision: 1},
|
|
{Mode: codersdk.ExperimentRuleModeOff, ExpectedRevision: -1},
|
|
} {
|
|
requireStatus(t, put(codersdk.ExperimentExample, req), http.StatusBadRequest)
|
|
}
|
|
for _, ex := range []codersdk.Experiment{codersdk.ExperimentWorkspaceUsage, "not-an-experiment"} {
|
|
requireStatus(t, put(ex, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn}), http.StatusBadRequest)
|
|
}
|
|
|
|
// A stale revision conflicts; the same rule at the current
|
|
// revision is an accepted no-op.
|
|
requireStatus(t, put(codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOff}), http.StatusConflict)
|
|
same, err := w.client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 1})
|
|
require.NoError(t, err)
|
|
require.Equal(t, stored, same)
|
|
|
|
entries, err := w.client.ExperimentRules(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []codersdk.ExperimentRuleEntry{
|
|
{Experiment: string(codersdk.ExperimentExample), Rule: &stored},
|
|
{Experiment: string(codersdk.ExperimentMCPToolSearch)},
|
|
}, entries)
|
|
})
|
|
|
|
// Authorization runs before the body is parsed or compiled: a caller
|
|
// without update access gets 403 even for an invalid condition.
|
|
t.Run("Authorization", func(t *testing.T) {
|
|
t.Parallel()
|
|
ownerClient := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
auditorClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleAuditor())
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
member := codersdk.NewExperimentalClient(memberClient)
|
|
_, err := member.ExperimentRules(ctx)
|
|
requireStatus(t, err, http.StatusForbidden)
|
|
|
|
auditor := codersdk.NewExperimentalClient(auditorClient)
|
|
_, err = auditor.ExperimentRules(ctx)
|
|
require.NoError(t, err)
|
|
|
|
for _, client := range []*codersdk.ExperimentalClient{member, auditor} {
|
|
for _, req := range []codersdk.PutExperimentRuleRequest{
|
|
{Mode: codersdk.ExperimentRuleModeOn},
|
|
{Mode: codersdk.ExperimentRuleModeCondition, Condition: "user."},
|
|
} {
|
|
_, err := client.PutExperimentRule(ctx, codersdk.ExperimentExample, req)
|
|
requireStatus(t, err, http.StatusForbidden)
|
|
}
|
|
}
|
|
require.Nil(t, ruleEntry(ctx, t, codersdk.NewExperimentalClient(ownerClient), codersdk.ExperimentExample).Rule)
|
|
})
|
|
|
|
// Stored rules the API cannot write are listed but ignored, and a
|
|
// malformed rule of a user-scoped experiment stays replaceable, even
|
|
// when its revision is unreadable.
|
|
t.Run("StoredRulesOutsideTheAPI", func(t *testing.T) {
|
|
t.Parallel()
|
|
ownerClient, db := coderdtest.NewWithDatabase(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
|
for ex, value := range map[codersdk.Experiment]string{
|
|
"not-an-experiment": `{"mode":"on","revision":1}`,
|
|
codersdk.ExperimentWorkspaceUsage: `{"mode":"off","revision":4}`,
|
|
codersdk.ExperimentExample: `{"mode":"percent","revision":2}`,
|
|
codersdk.ExperimentMCPToolSearch: `{not json`,
|
|
} {
|
|
require.NoError(t, db.UpsertExperimentRule(sysCtx, database.UpsertExperimentRuleParams{Experiment: string(ex), Value: value}))
|
|
}
|
|
client := codersdk.NewExperimentalClient(ownerClient)
|
|
|
|
entries, err := client.ExperimentRules(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []codersdk.ExperimentRuleEntry{
|
|
{Experiment: string(codersdk.ExperimentExample), Rule: &codersdk.ExperimentRule{Revision: 2}},
|
|
{Experiment: string(codersdk.ExperimentMCPToolSearch), Rule: &codersdk.ExperimentRule{}},
|
|
{Experiment: "not-an-experiment", Rule: &codersdk.ExperimentRule{Mode: string(codersdk.ExperimentRuleModeOn), Revision: 1}, Ignored: true},
|
|
{Experiment: string(codersdk.ExperimentWorkspaceUsage), Rule: &codersdk.ExperimentRule{Mode: string(codersdk.ExperimentRuleModeOff), Revision: 4}, Ignored: true},
|
|
}, entries)
|
|
|
|
rule, err := client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 2})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(3), rule.Revision)
|
|
|
|
_, err = client.PutExperimentRule(ctx, codersdk.ExperimentMCPToolSearch, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 5})
|
|
requireStatus(t, err, http.StatusConflict)
|
|
rule, err = client.PutExperimentRule(ctx, codersdk.ExperimentMCPToolSearch, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 0})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), rule.Revision)
|
|
for _, c := range []*codersdk.Client{ownerClient, memberClient} {
|
|
requireEnabled(ctx, t, c, codersdk.ExperimentExample, true)
|
|
requireEnabled(ctx, t, c, codersdk.ExperimentMCPToolSearch, true)
|
|
}
|
|
|
|
// An on rule without a positive revision is malformed: it is
|
|
// listed at revision 0 and decides off for everyone.
|
|
for ex, value := range map[codersdk.Experiment]string{
|
|
codersdk.ExperimentExample: `{"mode":"on","revision":0}`,
|
|
codersdk.ExperimentMCPToolSearch: `{"mode":"on"}`,
|
|
} {
|
|
require.NoError(t, db.UpsertExperimentRule(sysCtx, database.UpsertExperimentRuleParams{Experiment: string(ex), Value: value}))
|
|
require.Equal(t, &codersdk.ExperimentRule{}, ruleEntry(ctx, t, client, ex).Rule, ex)
|
|
}
|
|
for _, c := range []*codersdk.Client{ownerClient, memberClient} {
|
|
requireEnabled(ctx, t, c, codersdk.ExperimentExample, false)
|
|
requireEnabled(ctx, t, c, codersdk.ExperimentMCPToolSearch, false)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExperimentRulesReplicas checks that a rule written through one
|
|
// replica decides the next evaluation on another replica sharing the
|
|
// database, both for the API and for the evaluator chatd uses.
|
|
func TestExperimentRulesReplicas(t *testing.T) {
|
|
t.Parallel()
|
|
db, ps := dbtestutil.NewDB(t)
|
|
options := func() *coderdtest.Options {
|
|
return &coderdtest.Options{Database: db, Pubsub: ps}
|
|
}
|
|
clientA := coderdtest.New(t, options())
|
|
clientB, _, apiB := coderdtest.NewWithAPI(t, options())
|
|
owner := coderdtest.CreateFirstUser(t, clientA)
|
|
memberA, member := coderdtest.CreateAnotherUser(t, clientA, owner.OrganizationID)
|
|
memberB := codersdk.New(clientB.URL)
|
|
memberB.SetSessionToken(memberA.SessionToken())
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
w := newRuleWriter(t, clientA)
|
|
|
|
requireB := func(want bool) {
|
|
t.Helper()
|
|
requireEnabled(ctx, t, memberB, codersdk.ExperimentExample, want)
|
|
require.Equal(t, want, apiB.ExperimentEvaluator.Enabled(ctx, member.ID, codersdk.ExperimentExample))
|
|
}
|
|
requireB(false)
|
|
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.id == %q", member.ID))
|
|
requireB(true)
|
|
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOff, "")
|
|
requireB(false)
|
|
}
|