Compare commits

...

29 Commits

Author SHA1 Message Date
Kyle Carberry 7b8f87d325 feat(site): add landscape mode for Desktop panel on mobile
On mobile, the Desktop tab in the right panel now shows a fullscreen
button. Entering fullscreen triggers landscape orientation via the
Screen Orientation API so the VNC desktop view fills the screen.
Exiting fullscreen automatically restores portrait.

- manifest.json: adds "orientation": "portrait" for PWA portrait lock.
- useAgentsPWA: locks portrait on mount (best-effort, ignored where
  unsupported).
- useDesktopLandscape hook: locks/unlocks landscape orientation tied
  to a boolean flag. Restores portrait on unmount.
- SidebarTabView: shows fullscreen button on mobile when Desktop tab
  is active, and a minimize button when expanded.
- AgentChatPageView: calls useDesktopLandscape tied to
  isRightPanelExpanded && desktop tab active.
2026-04-01 12:36:33 +00:00
Kyle Carberry ee84b2aba9 feat(site): add landscape mode for Desktop panel on mobile
Adds a YouTube-style orientation toggle scoped to the Desktop tab in
the agents right panel. The app is portrait-locked by default (via
manifest.json and Screen Orientation API) and when the Desktop VNC
panel is active on mobile, a "Landscape" button appears overlaid on
the canvas. Tapping it unlocks orientation to landscape so the
desktop view fills the screen. The tab bar also shows a smartphone
icon to exit landscape when active.

- New useDesktopMode hook: manages landscape orientation via Screen
  Orientation API. Automatically restores portrait on unmount or
  when the user exits.
- manifest.json: adds "orientation": "portrait" for PWA portrait lock.
- useAgentsPWA: locks portrait on mount via Screen Orientation API
  (best-effort, silently ignored where unsupported).
- DesktopPanel: shows a "Landscape" button (mobile only) when the
  VNC session is connected.
- SidebarTabView: shows a smartphone exit button in the tab bar
  when landscape is active on the Desktop tab.
2026-04-01 12:15:25 +00:00
Cian Johnston a164d508cf fix(coderd/x/chatd): gate control subscriber to ignore stale pubsub notifications (#23865)
Fixes flaky `TestOpenAIReasoningWithWebSearchRoundTripStoreFalse` and
`TestOpenAIReasoningWithWebSearchRoundTrip`.

## Changes

- Gate the `processChat` control subscriber's cancel callback behind a
`chan struct{}` that is closed after publishing `"running"` status
- Add `TestGatedControlCancel` with 4 subtests exercising the gate logic

<details>
<summary>Root cause analysis</summary>

`SendMessage` publishes a `"pending"` notification on
`chat:stream:<chatID>` via PostgreSQL `NOTIFY`. `processChat` subscribes
to the same channel for control signals. Due to async NOTIFY delivery,
the `"pending"` notification can arrive at the control subscriber
**after** it registers its queue — even though it was published
**before**. `shouldCancelChatFromControlNotification("pending")` returns
`true`, immediately self-interrupting the processor before it does any
work.

The fix gates the cancel callback behind a closed channel. The channel
is closed after `processChat` publishes `"running"` status, so stale
notifications from before initialization are harmlessly ignored.
`close()` provides a happens-before guarantee in the Go memory model.
</details>

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-03-31 22:55:20 +01:00
Kayla はな b9f140e53e chore: remove Language objects (#23866) 2026-03-31 15:26:59 -06:00
Jeremy Ruppel 7f7b13f0ab fix(site): share AI Bridge entitlement/permissions logic (#23834)
Introduces a new `getAIBridgePermissions` method that all AI Bridge
pages can use to restrict access/paywall. Also adds the paywall and
alert to the session threads page bc I totes forgot.

---------

Co-authored-by: Jake Howell <jacob@coder.com>
2026-03-31 17:19:46 -04:00
Michael Suchacz e2bbd12137 test(coderd/x/chatd): remove flaky OpenAI round-trip tests (#23877) 2026-03-31 17:04:56 -04:00
Danielle Maywood e769d1bd7d fix(site): update story play functions after HelpTooltip→HelpPopover migration (#23876) 2026-03-31 21:50:05 +01:00
Jeremy Ruppel cccb680ec2 chore: remove shared workspaces beta badge (#23873) 2026-03-31 16:25:42 -04:00
Danielle Maywood e8fb418820 fix(site): delay desktop VNC connection until tab is selected (#23861) 2026-03-31 18:42:36 +01:00
Kyle Carberry 2c5e003c91 refactor(site): use hover popover for context indicator with nested skill tooltips (#23870)
Replaces the tooltip-inside-tooltip approach for the context usage
indicator with a hover-based Popover. Skill descriptions now appear as
nested tooltips to the right, matching the ModelSelector pattern.

**Before**: Tooltip with inline skill descriptions (truncated, janky
nested tooltips)
**After**: Popover opens on hover, skill names listed cleanly,
descriptions appear to the right on hover

- Popover opens on `mouseEnter`, closes after 150ms delay on
`mouseLeave`
- `onOpenAutoFocus` prevented to avoid stealing chat input focus
- Mobile keeps tap-to-toggle Popover behavior
- Skill rows get subtle `hover:bg-surface-tertiary` highlight
- `TooltipProvider` with `delayDuration={300}` wraps skill items (same
as ModelSelector)
2026-03-31 13:40:26 -04:00
code-qtzl f44a8994da fix(site): improve keyboard navigation in help popovers (#23374) 2026-03-31 11:10:33 -06:00
Yevhenii Shcherbina 84b94a8376 feat: add chatgpt support for aibridge proxy (#23826)
Add ChatGPT support for AIBridgeProxy
2026-03-31 12:54:38 -04:00
Cian Johnston 2a990ce758 feat: show friendly alert for missing agents-access role (#23831)
Replaces the generic red `ErrorAlert` ("Forbidden.") with a proactive
permission check and friendly info alert when a user lacks the
`agents-access` role.

- Add `createChat` permission check to `permissions.json` using
`owner_id: "me"`
- Handle `"me"` owner substitution in `renderPermissions` (SSR path)
- Pass `canCreateChat` from `useAuthenticated().permissions` into
`AgentCreateForm`
- Show `ChatAccessDeniedAlert` and disable input immediately (no need to
trigger a 403 first)
- Also catch 403 errors as a fallback in case permissions aren't yet
loaded
- Add `ForbiddenNoAgentsRole` Storybook story with `play` assertions
- Add `TestRenderPermissionsResolvesMe` Go test to pin the `"me"`
sentinel substitution

<details><summary>Implementation plan & decision log</summary>

- Uses the existing `permissions.json` + `checkAuthorization` system
rather than a separate API call
- `owner_id: "me"` is resolved to the actor's ID by both the auth-check
API endpoint and the SSR `renderPermissions` function
- Go test uses a real `rbac.StrictCachingAuthorizer` (not a mock) so it
verifies both the sentinel substitution and the RBAC role evaluation
end-to-end
- Alert follows the exact same `Alert` pattern as the 409 usage-limit
block
- Uses `severity="info"` and links to the getting-started docs Step 3
- Textarea is disabled proactively so the user never sees the scary
generic error

</details>

> 🤖 Created by a Coder Agent and will be reviewed by a human.
2026-03-31 17:26:58 +01:00
Danny Kopping c86f1288f1 chore: update aibridge with latest changes (#23863)
https://github.com/coder/aibridge/compare/519b082ad666...a011104f377d

Includes https://github.com/coder/aibridge/pull/242 and
https://github.com/coder/aibridge/pull/229

Signed-off-by: Danny Kopping <danny@coder.com>
2026-03-31 16:11:50 +00:00
Yevhenii Shcherbina 9440adf435 feat: add chatgpt support for aibridge (#23822)
Registers a new aibridge provider for ChatGPT by reusing the existing
OpenAI provider with a different `Name` and `BaseURL`
(https://chatgpt.com/backend-api/codex). The ChatGPT backend API is
OpenAI-compatible, so no new provider type is needed.
ChatGPT authenticates exclusively via per-user OAuth JWTs (BYOK mode) —
no centralized API key is configured. The OpenAI provider already
handles this: when no key is set, it falls through to the bearer token
from the request's Authorization header.
  
  Depends on #23811
2026-03-31 12:08:45 -04:00
Kayla はな 755e8be5ad chore: migrate some emotion styles to tailwind (#23817) 2026-03-31 10:07:33 -06:00
Danielle Maywood c9e335c453 refactor: redesign compaction settings to table layout with batch save (#23844) 2026-03-31 17:06:12 +01:00
Jeremy Ruppel 2d1f35f8a6 feat(site): session timeline design feedback (#23836)
- Remove `border-surface-secondary` from all line art and use the
default border color
- Use `text-sm` for all timeline elements and tables *(except the
`Thinking...` mono font, that's `text-xs`)

---------

Co-authored-by: Jake Howell <jacob@coder.com>
2026-03-31 12:02:19 -04:00
Susana Ferreira b0036af57b feat: register multiple Copilot providers for business and enterprise upstreams (#23811)
## Description

Adds support for multiple Copilot provider instances to route requests to different Copilot upstreams (individual, business, enterprise). Each instance has its own name and base URL, enabling per-upstream metrics, logs, circuit breakers, API dump, and routing.

## Changes

* Add Copilot business and enterprise provider names and host constants
* Register three Copilot provider instances in aibridged (default, business, enterprise)
* Update `defaultAIBridgeProvider` in `aibridgeproxy` to route new Copilot hosts to their corresponding providers

## Related

* Depends on: https://github.com/coder/aibridge/pull/240
* Closes: https://github.com/coder/aibridge/issues/152

Note: documentation changes will be added in a follow-up PR.

_Disclaimer: initially produced by Claude Opus 4.6, heavily modified and reviewed by @ssncferreira ._
2026-03-31 16:00:37 +01:00
Kyle Carberry 2953245862 feat(site): display loaded context files and skills in context indicator tooltip (#23853)
Renders the `last_injected_context` data (AGENTS.md files and skills)
from the Chat API in the `ContextUsageIndicator` hover tooltip. On
hover, users now see:

- **Context files**: basename with full path on title hover, truncation
indicator
- **Skills**: name and optional description

Separated from the existing token usage info by a border divider when
both sections are present. Added `max-w-72` to prevent the tooltip from
getting too wide.

<img width="970" height="598" alt="image"
src="https://github.com/user-attachments/assets/5bc25cb2-1d92-41d2-ab1a-63e5e49f667a"
/>

<details>
<summary>Data flow</summary>

```
chatQuery.data.last_injected_context
  → AgentChatPage (AgentChatPageView prop)
    → AgentChatPageView (ChatPageInput prop)
      → ChatPageInput (spread into latestContextUsage)
        → AgentChatInput (contextUsage prop)
          → ContextUsageIndicator (usage.lastInjectedContext)
```

</details>

<details>
<summary>Files changed</summary>

| File | Change |
|---|---|
| `ContextUsageIndicator.tsx` | Add `lastInjectedContext` to interface,
render context files and skills sections in tooltip |
| `ChatPageContent.tsx` | Thread `lastInjectedContext` prop, spread into
context usage object |
| `AgentChatPageView.tsx` | Thread `lastInjectedContext` prop to
`ChatPageInput` |
| `AgentChatPage.tsx` | Pass `chatQuery.data?.last_injected_context`
down |

</details>
2026-03-31 14:43:32 +00:00
Danny Kopping 5d07014f9f chore: update aibridge lib (#23849)
https://github.com/coder/aibridge/pull/230 has been merged, update the
dependency to match.

Includes other changes as well:
https://github.com/coder/aibridge/compare/dd8c239e5566...77d597aa123b
(cc @evgeniy-scherbina, @pawbana)

Signed-off-by: Danny Kopping <danny@coder.com>
2026-03-31 16:11:40 +02:00
Jeremy Ruppel 002e88fefc fix(site): use mock client model in sessions list view (#23851)
Missed this lil guy when adding the `<ClientFilter />` in #23733
2026-03-31 10:00:21 -04:00
Ethan bbf3fbc830 fix(coderd/x/chatd): archive chat hard-interrupts active stream (#23758)
Archiving a chat now transitions pending or running chats to waiting
before setting the archived flag. This publishes a status notification
on `ChatStreamNotifyChannel` so `subscribeChatControl` cancels the
active `processChat` context via `ErrInterrupted` — the same codepath
used by the stop button.

The `processChat` cleanup also skips queued-message auto-promotion when
the chat is archived, so archiving behaves like a hard stop rather than
interrupt-and-continue.

Relates to https://github.com/coder/coder/issues/23666
2026-04-01 00:23:52 +11:00
Danny Kopping 9fa103929a perf: make ListAIBridgeSessions 10x faster (#23774)
_Disclaimer: produced using Claude Opus 4.6, reviewed by me, and
validated against Dogfood dataset._

The `ListAIBridgeSessions` query materialized and aggregated all
matching interceptions before paginating, then ran expensive
token/prompt lookups across the full dataset. For a page of 25 sessions
against ~200k interceptions (our dogfood dataset), this meant:
- Three CTEs scanning all rows (filtered_interceptions, session_tokens,
session_root)
  - ARRAY_AGG(fi.id) collecting every interception ID per session
- Lateral prompt lookup via ANY(array_of_all_ids) running for every
session, not just the page
  - ~90MB of disk sorts and JIT compilation kicking in

The improvement is to restructure to paginate first and enrich after: a
single CTE groups interceptions into sessions with only cheap aggregates
(MIN, MAX, COUNT), applies cursor pagination and LIMIT, then lateral
joins fetch metadata, tokens, and prompts for just the ~25-row page.

  Measured against 220k interceptions / 160k sessions:

  | Metric             | Before | After |
  |--------------------|--------|-------|
  | Execution time     | 1800ms | 185ms |
  | Shared buffer hits | 737k   | 2.6k  |
  | Disk sort spill    | 86MB   | 16MB  |
  | Lateral loops      | 160k   | 25    |

https://grafana.dev.coder.com/goto/fbODPGtvR?orgId=1 the results are
identical, just _much_ faster.

--- 

Also includes some additional tests which I added prior to refactoring
the query to ensure no regressions on edge-cases.

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2026-03-31 14:42:23 +02:00
Lukasz acd2ff63a7 chore: bump Go toolchain to 1.25.8 (#23772)
Bump the repository Go toolchain from 1.25.7 to 1.25.8.

Updates `go.mod`, the shared `setup-go` action default, and the dogfood
image checksum so local, CI, and dogfood builds stay aligned.
2026-03-31 14:04:58 +02:00
Atif Ali e3e17e15f7 fix(site): show accurate message and warning color for startup script failures in agent row (#23654)
The agent row tooltip showed "Error starting the agent" / "Something
went wrong during the agent startup" with a red border when a startup
script fails. This is misleading — the agent is started and functional,
only the startup script exited with a non-zero code.

Extracts shared message constants (`agentLifecycleMessages`,
`agentStatusMessages`) from `health.ts` so both the workspace-level
health classification and the per-agent-row tooltips reference the same
single source of truth. No more duplicated wording that can drift.

Changes:
- **`health.ts`**: Exports `agentLifecycleMessages` and
`agentStatusMessages` maps; `getAgentHealthIssue` now references them
instead of inline strings.
- **`AgentStatus.tsx`**: All lifecycle/status tooltip components
(`StartErrorLifecycle`, `StartTimeoutLifecycle`,
`ShutdownTimeoutLifecycle`, `ShutdownErrorLifecycle`, `TimeoutStatus`)
now import and render from the shared message constants.
`StartErrorLifecycle` icon changed from red (`errorWarning`) to orange
(`timeoutWarning`).
- **`AgentRow.tsx`**: `start_error` border changed from
`border-border-destructive` (red) to `border-border-warning` (orange).

Closes #23652
Refs #21389

> 🤖 This PR was created with the help of Coder Agents, and has been
reviewed by my human. 🧑‍💻
2026-03-31 12:04:51 +00:00
Michael Suchacz af678606fc fix(coderd/x/chatd): stabilize flaky request-count assertion in round-trip test (#23843)
The flaky test assumed the second streamed OpenAI request had already
been captured when the chat status event arrived. In practice, the
capture server can record that second request slightly later, which
intermittently left `streamRequestCount` at `1`.

This change waits for the second captured request before asserting on
the follow-up payload and relaxes the count check to a sanity check. The
test still verifies the `store=false` round-trip behavior without
depending on that timing race.

Fixes coder/internal#1433
2026-03-31 13:09:11 +02:00
Cian Johnston 3190406de3 fix(site): stop workspace deletes playing hide-and-seek (#23641)
- Fix workspaces list invalidation after kebab-menu delete and add
Storybook coverage for the immediate `Deleting` state.

> 🤖 This PR was made by Coder Agents and read by me.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 11:36:47 +01:00
Cian Johnston 3ce82bb885 feat: add chat-access site-wide role to gate chat creation (#23724)
- Add `chat-access` built-in role granting chat CRUD at User scope
- Exclude `ResourceChat` from member, org member, and org service
account `allPermsExcept` calls
- Allow system, owner, and user-admin to assign the new role
- Migration auto-assigns role to users who have ever created a chat
- Update RBAC test matrix: `memberMe` denied, `chatAccessUser` allowed

**Breaking change**: Members without `chat-access` lose chat creation
ability. Migration covers existing chat creators. Members who have never
created a chat do not get this role automatically applied.

> 🤖 This PR was created by a Coder Agent and reviewed by me.
2026-03-31 10:07:21 +01:00
241 changed files with 4071 additions and 2754 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.25.7"
default: "1.25.8"
use-cache:
description: "Whether to use the cache."
default: "true"
+29 -9
View File
@@ -240,6 +240,7 @@ jobs:
- name: Create Coder Task for Documentation Check
if: steps.check-secrets.outputs.skip != 'true'
id: create_task
continue-on-error: true
uses: ./.github/actions/create-task-action
with:
coder-url: ${{ secrets.DOC_CHECK_CODER_URL }}
@@ -254,8 +255,21 @@ jobs:
github-issue-url: ${{ steps.determine-context.outputs.pr_url }}
comment-on-issue: false
- name: Handle Task Creation Failure
if: steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome != 'success'
run: |
{
echo "## Documentation Check Task"
echo ""
echo "⚠️ The external Coder task service was unavailable, so this"
echo "advisory documentation check did not run."
echo ""
echo "Maintainers can rerun the workflow or trigger it manually"
echo "after the service recovers."
} >> "${GITHUB_STEP_SUMMARY}"
- name: Write Task Info
if: steps.check-secrets.outputs.skip != 'true'
if: steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
env:
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
@@ -273,7 +287,7 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"
- name: Wait for Task Completion
if: steps.check-secrets.outputs.skip != 'true'
if: steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
id: wait_task
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
@@ -363,7 +377,7 @@ jobs:
fi
- name: Fetch Task Logs
if: always() && steps.check-secrets.outputs.skip != 'true'
if: always() && steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
run: |
@@ -376,7 +390,7 @@ jobs:
echo "::endgroup::"
- name: Cleanup Task
if: always() && steps.check-secrets.outputs.skip != 'true'
if: always() && steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
run: |
@@ -390,6 +404,7 @@ jobs:
- name: Write Final Summary
if: always() && steps.check-secrets.outputs.skip != 'true'
env:
CREATE_TASK_OUTCOME: ${{ steps.create_task.outcome }}
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
TASK_MESSAGE: ${{ steps.wait_task.outputs.task_message }}
RESULT_URI: ${{ steps.wait_task.outputs.result_uri }}
@@ -400,10 +415,15 @@ jobs:
echo "---"
echo "### Result"
echo ""
echo "**Status:** ${TASK_MESSAGE:-Task completed}"
if [[ -n "${RESULT_URI}" ]]; then
echo "**Comment:** ${RESULT_URI}"
if [[ "${CREATE_TASK_OUTCOME}" == "success" ]]; then
echo "**Status:** ${TASK_MESSAGE:-Task completed}"
if [[ -n "${RESULT_URI}" ]]; then
echo "**Comment:** ${RESULT_URI}"
fi
echo ""
echo "Task \`${TASK_NAME}\` has been cleaned up."
else
echo "**Status:** Skipped because the external Coder task"
echo "service was unavailable."
fi
echo ""
echo "Task \`${TASK_NAME}\` has been cleaned up."
} >> "${GITHUB_STEP_SUMMARY}"
+7 -2
View File
@@ -857,13 +857,18 @@ aibridgeproxy:
# Comma-separated list of AI provider domains for which HTTPS traffic will be
# decrypted and routed through AI Bridge. Requests to other domains will be
# tunneled directly without decryption. Supported domains: api.anthropic.com,
# api.openai.com, api.individual.githubcopilot.com.
# (default: api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,
# api.openai.com, api.individual.githubcopilot.com,
# api.business.githubcopilot.com, api.enterprise.githubcopilot.com, chatgpt.com.
# (default:
# api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,chatgpt.com,
# type: string-array)
domain_allowlist:
- api.anthropic.com
- api.openai.com
- api.individual.githubcopilot.com
- api.business.githubcopilot.com
- api.enterprise.githubcopilot.com
- chatgpt.com
# URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests
# through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.
# (default: <unset>, type: string)
+15
View File
@@ -20,6 +20,21 @@ const HeaderCoderToken = "X-Coder-AI-Governance-Token" //nolint:gosec // This is
// request forwarded to aibridged for cross-service log correlation.
const HeaderCoderRequestID = "X-Coder-AI-Governance-Request-Id"
// Copilot provider.
const (
ProviderCopilotBusiness = "copilot-business"
HostCopilotBusiness = "api.business.githubcopilot.com"
ProviderCopilotEnterprise = "copilot-enterprise"
HostCopilotEnterprise = "api.enterprise.githubcopilot.com"
)
// ChatGPT provider.
const (
ProviderChatGPT = "chatgpt"
HostChatGPT = "chatgpt.com"
BaseURLChatGPT = "https://" + HostChatGPT + "/backend-api/codex"
)
// IsBYOK reports whether the request is using BYOK mode, determined
// by the presence of the X-Coder-AI-Governance-Token header.
func IsBYOK(header http.Header) bool {
+1 -1
View File
@@ -220,7 +220,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
Type: string(v.Object.ResourceType),
AnyOrgOwner: v.Object.AnyOrgOwner,
}
if obj.Owner == "me" {
if obj.Owner == codersdk.Me {
obj.Owner = auth.ID
}
+9 -1
View File
@@ -2811,7 +2811,15 @@ func (q *querier) GetDERPMeshKey(ctx context.Context) (string, error) {
}
func (q *querier) GetDefaultChatModelConfig(ctx context.Context) (database.ChatModelConfig, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
// Any user who can read chat resources can read the default
// model config, since model resolution is required to create
// a chat. This avoids gating on ResourceDeploymentConfig
// which regular members lack.
act, ok := ActorFromContext(ctx)
if !ok {
return database.ChatModelConfig{}, ErrNoActor
}
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(act.ID)); err != nil {
return database.ChatModelConfig{}, err
}
return q.db.GetDefaultChatModelConfig(ctx)
+1 -1
View File
@@ -631,7 +631,7 @@ func (s *MethodTestSuite) TestChats() {
s.Run("GetDefaultChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any()).Return(config, nil).AnyTimes()
check.Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config)
check.Asserts(rbac.ResourceChat.WithOwner(testActorID.String()), policy.ActionRead).Returns(config)
}))
s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
@@ -0,0 +1,4 @@
-- Remove 'agents-access' from all users who have it.
UPDATE users
SET rbac_roles = array_remove(rbac_roles, 'agents-access')
WHERE 'agents-access' = ANY(rbac_roles);
@@ -0,0 +1,5 @@
-- Grant 'agents-access' to every user who has ever created a chat.
UPDATE users
SET rbac_roles = array_append(rbac_roles, 'agents-access')
WHERE id IN (SELECT DISTINCT owner_id FROM chats)
AND NOT ('agents-access' = ANY(rbac_roles));
+146
View File
@@ -877,3 +877,149 @@ func TestMigration000387MigrateTaskWorkspaces(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 0, antCount, "antagonist workspaces (deleted and regular) should not be migrated")
}
func TestMigration000457ChatAccessRole(t *testing.T) {
t.Parallel()
const migrationVersion = 457
sqlDB := testSQLDB(t)
// Migrate up to the migration before the one that grants
// agents-access roles.
next, err := migrations.Stepper(sqlDB)
require.NoError(t, err)
for {
version, more, err := next()
require.NoError(t, err)
if !more {
t.Fatalf("migration %d not found", migrationVersion)
}
if version == migrationVersion-1 {
break
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Define test users.
userWithChat := uuid.New() // Has a chat, no agents-access role.
userAlreadyHasRole := uuid.New() // Has a chat and already has agents-access.
userNoChat := uuid.New() // No chat at all.
userWithChatAndRoles := uuid.New() // Has a chat and other existing roles.
now := time.Now().UTC().Truncate(time.Microsecond)
// We need a chat_provider and chat_model_config for the chats FK.
providerID := uuid.New()
modelConfigID := uuid.New()
tx, err := sqlDB.BeginTx(ctx, nil)
require.NoError(t, err)
defer tx.Rollback()
fixtures := []struct {
query string
args []any
}{
// Insert test users with varying rbac_roles.
{
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{userWithChat, "user-with-chat", "chat@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"},
},
{
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{userAlreadyHasRole, "user-already-has-role", "already@test.com", []byte{}, now, now, "active", pq.StringArray{"agents-access"}, "password"},
},
{
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{userNoChat, "user-no-chat", "nochat@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"},
},
{
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{userWithChatAndRoles, "user-with-roles", "roles@test.com", []byte{}, now, now, "active", pq.StringArray{"template-admin"}, "password"},
},
// Insert a chat provider and model config for the chats FK.
{
`INSERT INTO chat_providers (id, provider, display_name, api_key, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[]any{providerID, "openai", "OpenAI", "", true, now, now},
},
{
`INSERT INTO chat_model_configs (id, provider, model, display_name, enabled, context_limit, compression_threshold, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{modelConfigID, "openai", "gpt-4", "GPT 4", true, 100000, 70, now, now},
},
// Insert chats for users A, B, and D (not C).
{
`INSERT INTO chats (id, owner_id, last_model_config_id, title, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[]any{uuid.New(), userWithChat, modelConfigID, "Chat A", now, now},
},
{
`INSERT INTO chats (id, owner_id, last_model_config_id, title, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[]any{uuid.New(), userAlreadyHasRole, modelConfigID, "Chat B", now, now},
},
{
`INSERT INTO chats (id, owner_id, last_model_config_id, title, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[]any{uuid.New(), userWithChatAndRoles, modelConfigID, "Chat D", now, now},
},
}
for i, f := range fixtures {
_, err := tx.ExecContext(ctx, f.query, f.args...)
require.NoError(t, err, "fixture %d", i)
}
require.NoError(t, tx.Commit())
// Run the migration.
version, _, err := next()
require.NoError(t, err)
require.EqualValues(t, migrationVersion, version)
// Helper to get rbac_roles for a user.
getRoles := func(t *testing.T, userID uuid.UUID) []string {
t.Helper()
var roles pq.StringArray
err := sqlDB.QueryRowContext(ctx,
"SELECT rbac_roles FROM users WHERE id = $1", userID,
).Scan(&roles)
require.NoError(t, err)
return roles
}
// Verify: user with chat gets agents-access.
roles := getRoles(t, userWithChat)
require.Contains(t, roles, "agents-access",
"user with chat should get agents-access")
// Verify: user who already had agents-access has no duplicate.
roles = getRoles(t, userAlreadyHasRole)
count := 0
for _, r := range roles {
if r == "agents-access" {
count++
}
}
require.Equal(t, 1, count,
"user who already had agents-access should not get a duplicate")
// Verify: user without chat does NOT get agents-access.
roles = getRoles(t, userNoChat)
require.NotContains(t, roles, "agents-access",
"user without chat should not get agents-access")
// Verify: user with chat and existing roles gets agents-access
// appended while preserving existing roles.
roles = getRoles(t, userWithChatAndRoles)
require.Contains(t, roles, "agents-access",
"user with chat and other roles should get agents-access")
require.Contains(t, roles, "template-admin",
"existing roles should be preserved")
}
+2 -2
View File
@@ -996,8 +996,6 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
query := fmt.Sprintf("-- name: ListAuthorizedAIBridgeSessions :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.AfterSessionID,
arg.Offset,
arg.Limit,
arg.StartedAfter,
arg.StartedBefore,
arg.InitiatorID,
@@ -1005,6 +1003,8 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
arg.Model,
arg.Client,
arg.SessionID,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
+4
View File
@@ -788,6 +788,10 @@ type sqlcQuerier interface {
// Returns paginated sessions with aggregated metadata, token counts, and
// the most recent user prompt. A "session" is a logical grouping of
// interceptions that share the same session_id (set by the client).
//
// Pagination-first strategy: identify the page of sessions cheaply via a
// single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
// first-interception metadata) only for the ~page-size result set.
ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error)
ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error)
ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error)
+9 -3
View File
@@ -1251,8 +1251,12 @@ func TestGetAuthorizedChats(t *testing.T) {
owner := dbgen.User(t, db, database.User{
RBACRoles: []string{rbac.RoleOwner().String()},
})
member := dbgen.User(t, db, database.User{})
secondMember := dbgen.User(t, db, database.User{})
member := dbgen.User(t, db, database.User{
RBACRoles: pq.StringArray{rbac.RoleAgentsAccess().String()},
})
secondMember := dbgen.User(t, db, database.User{
RBACRoles: pq.StringArray{rbac.RoleAgentsAccess().String()},
})
// Create FK dependencies: a chat provider and model config.
ctx := testutil.Context(t, testutil.WaitMedium)
@@ -1407,7 +1411,9 @@ func TestGetAuthorizedChats(t *testing.T) {
// Use a dedicated user for pagination to avoid interference
// with the other parallel subtests.
paginationUser := dbgen.User(t, db, database.User{})
paginationUser := dbgen.User(t, db, database.User{
RBACRoles: pq.StringArray{rbac.RoleAgentsAccess().String()},
})
for i := range 7 {
_, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: paginationUser.ID,
+82 -83
View File
@@ -1347,95 +1347,87 @@ func (q *sqlQuerier) ListAIBridgeSessionThreads(ctx context.Context, arg ListAIB
}
const listAIBridgeSessions = `-- name: ListAIBridgeSessions :many
WITH filtered_interceptions AS (
WITH cursor_pos AS (
-- Resolve the cursor's started_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group.
SELECT MIN(aibridge_interceptions.started_at) AS started_at
FROM aibridge_interceptions
WHERE aibridge_interceptions.session_id = $1 AND aibridge_interceptions.ended_at IS NOT NULL
),
session_page AS (
-- Paginate at the session level first; only cheap aggregates here.
SELECT
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id
ai.session_id,
ai.initiator_id,
MIN(ai.started_at) AS started_at,
MAX(ai.ended_at) AS ended_at,
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
FROM
aibridge_interceptions
aibridge_interceptions ai
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
aibridge_interceptions.ended_at IS NOT NULL
ai.ended_at IS NOT NULL
-- Filter by time frame
AND CASE
WHEN $4::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= $4::timestamptz
WHEN $2::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at >= $2::timestamptz
ELSE true
END
AND CASE
WHEN $5::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= $5::timestamptz
WHEN $3::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at <= $3::timestamptz
ELSE true
END
-- Filter initiator_id
AND CASE
WHEN $6::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = $6::uuid
WHEN $4::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ai.initiator_id = $4::uuid
ELSE true
END
-- Filter provider
AND CASE
WHEN $7::text != '' THEN aibridge_interceptions.provider = $7::text
WHEN $5::text != '' THEN ai.provider = $5::text
ELSE true
END
-- Filter model
AND CASE
WHEN $8::text != '' THEN aibridge_interceptions.model = $8::text
WHEN $6::text != '' THEN ai.model = $6::text
ELSE true
END
-- Filter client
AND CASE
WHEN $9::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $9::text
WHEN $7::text != '' THEN COALESCE(ai.client, 'Unknown') = $7::text
ELSE true
END
-- Filter session_id
AND CASE
WHEN $10::text != '' THEN aibridge_interceptions.session_id = $10::text
WHEN $8::text != '' THEN ai.session_id = $8::text
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeSessions
-- @authorize_filter
),
session_tokens AS (
-- Aggregate token usage across all interceptions in each session.
-- Group by (session_id, initiator_id) to avoid merging sessions from
-- different users who happen to share the same client_session_id.
SELECT
fi.session_id,
fi.initiator_id,
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
-- TODO: add extra token types once https://github.com/coder/aibridge/issues/150 lands.
FROM
filtered_interceptions fi
LEFT JOIN
aibridge_token_usages tu ON fi.id = tu.interception_id
GROUP BY
fi.session_id, fi.initiator_id
),
session_root AS (
-- Build one summary row per session. Group by (session_id, initiator_id)
-- to avoid merging sessions from different users who happen to share the
-- same client_session_id. The ARRAY_AGG with ORDER BY picks values from
-- the chronologically first interception for fields that should represent
-- the session as a whole (client, metadata). Threads are counted as
-- distinct root interception IDs: an interception with a NULL
-- thread_root_id is itself a thread root.
SELECT
fi.session_id,
fi.initiator_id,
(ARRAY_AGG(fi.client ORDER BY fi.started_at, fi.id))[1] AS client,
(ARRAY_AGG(fi.metadata ORDER BY fi.started_at, fi.id))[1] AS metadata,
ARRAY_AGG(DISTINCT fi.provider ORDER BY fi.provider) AS providers,
ARRAY_AGG(DISTINCT fi.model ORDER BY fi.model) AS models,
MIN(fi.started_at) AS started_at,
MAX(fi.ended_at) AS ended_at,
COUNT(DISTINCT COALESCE(fi.thread_root_id, fi.id)) AS threads,
-- Collect IDs for lateral prompt lookup.
ARRAY_AGG(fi.id) AS interception_ids
FROM
filtered_interceptions fi
GROUP BY
fi.session_id, fi.initiator_id
ai.session_id, ai.initiator_id
HAVING
-- Cursor pagination: uses a composite (started_at, session_id)
-- cursor to support keyset pagination. The less-than comparison
-- matches the DESC sort order so rows after the cursor come
-- later in results. The cursor value comes from cursor_pos to
-- guarantee single evaluation.
CASE
WHEN $1::text != '' THEN (
(MIN(ai.started_at), ai.session_id) < (
(SELECT started_at FROM cursor_pos),
$1::text
)
)
ELSE true
END
ORDER BY
MIN(ai.started_at) DESC,
ai.session_id DESC
LIMIT COALESCE(NULLIF($10::integer, 0), 100)
OFFSET $9
)
SELECT
sr.session_id,
sp.session_id,
visible_users.id AS user_id,
visible_users.username AS user_username,
visible_users.name AS user_name,
@@ -1444,51 +1436,52 @@ SELECT
sr.models::text[] AS models,
COALESCE(sr.client, '')::varchar(64) AS client,
sr.metadata::jsonb AS metadata,
sr.started_at::timestamptz AS started_at,
sr.ended_at::timestamptz AS ended_at,
sr.threads,
sp.started_at::timestamptz AS started_at,
sp.ended_at::timestamptz AS ended_at,
sp.threads,
COALESCE(st.input_tokens, 0)::bigint AS input_tokens,
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(slp.prompt, '') AS last_prompt
FROM
session_root sr
session_page sp
JOIN
visible_users ON visible_users.id = sr.initiator_id
LEFT JOIN
session_tokens st ON st.session_id = sr.session_id AND st.initiator_id = sr.initiator_id
visible_users ON visible_users.id = sp.initiator_id
LEFT JOIN LATERAL (
-- Lateral join to efficiently fetch only the most recent user prompt
-- across all interceptions in the session, avoiding a full aggregation.
SELECT
(ARRAY_AGG(ai.client ORDER BY ai.started_at, ai.id))[1] AS client,
(ARRAY_AGG(ai.metadata ORDER BY ai.started_at, ai.id))[1] AS metadata,
ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider) AS providers,
ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model) AS models,
ARRAY_AGG(ai.id) AS interception_ids
FROM aibridge_interceptions ai
WHERE ai.session_id = sp.session_id
AND ai.initiator_id = sp.initiator_id
AND ai.ended_at IS NOT NULL
) sr ON true
LEFT JOIN LATERAL (
-- Aggregate tokens only for this session's interceptions.
SELECT
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
FROM aibridge_token_usages tu
WHERE tu.interception_id = ANY(sr.interception_ids)
) st ON true
LEFT JOIN LATERAL (
-- Fetch only the most recent user prompt across all interceptions
-- in the session.
SELECT up.prompt
FROM aibridge_user_prompts up
WHERE up.interception_id = ANY(sr.interception_ids)
ORDER BY up.created_at DESC, up.id DESC
LIMIT 1
) slp ON true
WHERE
-- Cursor pagination: uses a composite (started_at, session_id) cursor
-- to support keyset pagination. The less-than comparison matches the
-- DESC sort order so that rows after the cursor come later in results.
CASE
WHEN $1::text != '' THEN (
(sr.started_at, sr.session_id) < (
(SELECT started_at FROM session_root WHERE session_id = $1),
$1::text
)
)
ELSE true
END
ORDER BY
sr.started_at DESC,
sr.session_id DESC
LIMIT COALESCE(NULLIF($3::integer, 0), 100)
OFFSET $2
sp.started_at DESC,
sp.session_id DESC
`
type ListAIBridgeSessionsParams struct {
AfterSessionID string `db:"after_session_id" json:"after_session_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
StartedAfter time.Time `db:"started_after" json:"started_after"`
StartedBefore time.Time `db:"started_before" json:"started_before"`
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
@@ -1496,6 +1489,8 @@ type ListAIBridgeSessionsParams struct {
Model string `db:"model" json:"model"`
Client string `db:"client" json:"client"`
SessionID string `db:"session_id" json:"session_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
}
type ListAIBridgeSessionsRow struct {
@@ -1519,11 +1514,13 @@ type ListAIBridgeSessionsRow struct {
// Returns paginated sessions with aggregated metadata, token counts, and
// the most recent user prompt. A "session" is a logical grouping of
// interceptions that share the same session_id (set by the client).
//
// Pagination-first strategy: identify the page of sessions cheaply via a
// single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
// first-interception metadata) only for the ~page-size result set.
func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error) {
rows, err := q.db.QueryContext(ctx, listAIBridgeSessions,
arg.AfterSessionID,
arg.Offset,
arg.Limit,
arg.StartedAfter,
arg.StartedBefore,
arg.InitiatorID,
@@ -1531,6 +1528,8 @@ func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeS
arg.Model,
arg.Client,
arg.SessionID,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
+78 -79
View File
@@ -454,95 +454,91 @@ WHERE
-- Returns paginated sessions with aggregated metadata, token counts, and
-- the most recent user prompt. A "session" is a logical grouping of
-- interceptions that share the same session_id (set by the client).
WITH filtered_interceptions AS (
--
-- Pagination-first strategy: identify the page of sessions cheaply via a
-- single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
-- first-interception metadata) only for the ~page-size result set.
WITH cursor_pos AS (
-- Resolve the cursor's started_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group.
SELECT MIN(aibridge_interceptions.started_at) AS started_at
FROM aibridge_interceptions
WHERE aibridge_interceptions.session_id = @after_session_id AND aibridge_interceptions.ended_at IS NOT NULL
),
session_page AS (
-- Paginate at the session level first; only cheap aggregates here.
SELECT
aibridge_interceptions.*
ai.session_id,
ai.initiator_id,
MIN(ai.started_at) AS started_at,
MAX(ai.ended_at) AS ended_at,
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
FROM
aibridge_interceptions
aibridge_interceptions ai
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
aibridge_interceptions.ended_at IS NOT NULL
ai.ended_at IS NOT NULL
-- Filter by time frame
AND CASE
WHEN @started_after::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= @started_after::timestamptz
WHEN @started_after::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at >= @started_after::timestamptz
ELSE true
END
AND CASE
WHEN @started_before::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= @started_before::timestamptz
WHEN @started_before::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at <= @started_before::timestamptz
ELSE true
END
-- Filter initiator_id
AND CASE
WHEN @initiator_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = @initiator_id::uuid
WHEN @initiator_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ai.initiator_id = @initiator_id::uuid
ELSE true
END
-- Filter provider
AND CASE
WHEN @provider::text != '' THEN aibridge_interceptions.provider = @provider::text
WHEN @provider::text != '' THEN ai.provider = @provider::text
ELSE true
END
-- Filter model
AND CASE
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
WHEN @model::text != '' THEN ai.model = @model::text
ELSE true
END
-- Filter client
AND CASE
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
WHEN @client::text != '' THEN COALESCE(ai.client, 'Unknown') = @client::text
ELSE true
END
-- Filter session_id
AND CASE
WHEN @session_id::text != '' THEN aibridge_interceptions.session_id = @session_id::text
WHEN @session_id::text != '' THEN ai.session_id = @session_id::text
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeSessions
-- @authorize_filter
),
session_tokens AS (
-- Aggregate token usage across all interceptions in each session.
-- Group by (session_id, initiator_id) to avoid merging sessions from
-- different users who happen to share the same client_session_id.
SELECT
fi.session_id,
fi.initiator_id,
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
-- TODO: add extra token types once https://github.com/coder/aibridge/issues/150 lands.
FROM
filtered_interceptions fi
LEFT JOIN
aibridge_token_usages tu ON fi.id = tu.interception_id
GROUP BY
fi.session_id, fi.initiator_id
),
session_root AS (
-- Build one summary row per session. Group by (session_id, initiator_id)
-- to avoid merging sessions from different users who happen to share the
-- same client_session_id. The ARRAY_AGG with ORDER BY picks values from
-- the chronologically first interception for fields that should represent
-- the session as a whole (client, metadata). Threads are counted as
-- distinct root interception IDs: an interception with a NULL
-- thread_root_id is itself a thread root.
SELECT
fi.session_id,
fi.initiator_id,
(ARRAY_AGG(fi.client ORDER BY fi.started_at, fi.id))[1] AS client,
(ARRAY_AGG(fi.metadata ORDER BY fi.started_at, fi.id))[1] AS metadata,
ARRAY_AGG(DISTINCT fi.provider ORDER BY fi.provider) AS providers,
ARRAY_AGG(DISTINCT fi.model ORDER BY fi.model) AS models,
MIN(fi.started_at) AS started_at,
MAX(fi.ended_at) AS ended_at,
COUNT(DISTINCT COALESCE(fi.thread_root_id, fi.id)) AS threads,
-- Collect IDs for lateral prompt lookup.
ARRAY_AGG(fi.id) AS interception_ids
FROM
filtered_interceptions fi
GROUP BY
fi.session_id, fi.initiator_id
ai.session_id, ai.initiator_id
HAVING
-- Cursor pagination: uses a composite (started_at, session_id)
-- cursor to support keyset pagination. The less-than comparison
-- matches the DESC sort order so rows after the cursor come
-- later in results. The cursor value comes from cursor_pos to
-- guarantee single evaluation.
CASE
WHEN @after_session_id::text != '' THEN (
(MIN(ai.started_at), ai.session_id) < (
(SELECT started_at FROM cursor_pos),
@after_session_id::text
)
)
ELSE true
END
ORDER BY
MIN(ai.started_at) DESC,
ai.session_id DESC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
)
SELECT
sr.session_id,
sp.session_id,
visible_users.id AS user_id,
visible_users.username AS user_username,
visible_users.name AS user_name,
@@ -551,45 +547,48 @@ SELECT
sr.models::text[] AS models,
COALESCE(sr.client, '')::varchar(64) AS client,
sr.metadata::jsonb AS metadata,
sr.started_at::timestamptz AS started_at,
sr.ended_at::timestamptz AS ended_at,
sr.threads,
sp.started_at::timestamptz AS started_at,
sp.ended_at::timestamptz AS ended_at,
sp.threads,
COALESCE(st.input_tokens, 0)::bigint AS input_tokens,
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(slp.prompt, '') AS last_prompt
FROM
session_root sr
session_page sp
JOIN
visible_users ON visible_users.id = sr.initiator_id
LEFT JOIN
session_tokens st ON st.session_id = sr.session_id AND st.initiator_id = sr.initiator_id
visible_users ON visible_users.id = sp.initiator_id
LEFT JOIN LATERAL (
-- Lateral join to efficiently fetch only the most recent user prompt
-- across all interceptions in the session, avoiding a full aggregation.
SELECT
(ARRAY_AGG(ai.client ORDER BY ai.started_at, ai.id))[1] AS client,
(ARRAY_AGG(ai.metadata ORDER BY ai.started_at, ai.id))[1] AS metadata,
ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider) AS providers,
ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model) AS models,
ARRAY_AGG(ai.id) AS interception_ids
FROM aibridge_interceptions ai
WHERE ai.session_id = sp.session_id
AND ai.initiator_id = sp.initiator_id
AND ai.ended_at IS NOT NULL
) sr ON true
LEFT JOIN LATERAL (
-- Aggregate tokens only for this session's interceptions.
SELECT
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
FROM aibridge_token_usages tu
WHERE tu.interception_id = ANY(sr.interception_ids)
) st ON true
LEFT JOIN LATERAL (
-- Fetch only the most recent user prompt across all interceptions
-- in the session.
SELECT up.prompt
FROM aibridge_user_prompts up
WHERE up.interception_id = ANY(sr.interception_ids)
ORDER BY up.created_at DESC, up.id DESC
LIMIT 1
) slp ON true
WHERE
-- Cursor pagination: uses a composite (started_at, session_id) cursor
-- to support keyset pagination. The less-than comparison matches the
-- DESC sort order so that rows after the cursor come later in results.
CASE
WHEN @after_session_id::text != '' THEN (
(sr.started_at, sr.session_id) < (
(SELECT started_at FROM session_root WHERE session_id = @after_session_id),
@after_session_id::text
)
)
ELSE true
END
ORDER BY
sr.started_at DESC,
sr.session_id DESC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
sp.started_at DESC,
sp.session_id DESC
;
-- name: ListAIBridgeSessionThreads :many
+24 -3
View File
@@ -393,6 +393,11 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
httpapi.Forbidden(rw)
return
}
var req codersdk.CreateChatRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
@@ -498,6 +503,10 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
})
return
}
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat.",
Detail: err.Error(),
@@ -616,6 +625,10 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
EndDate: endDate,
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
@@ -626,6 +639,10 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
EndDate: endDate,
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
@@ -636,6 +653,10 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
EndDate: endDate,
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
@@ -1620,9 +1641,9 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
}
var err error
// Use chatDaemon when available so it can notify active
// subscribers. Fall back to direct DB for the simple
// archive flag — no streaming state is involved.
// Use chatDaemon when available so it can interrupt active
// processing before broadcasting archive state. Fall back to
// direct DB when no daemon is running.
if archived {
if api.chatDaemon != nil {
err = api.chatDaemon.ArchiveChat(ctx, chat)
+67 -12
View File
@@ -194,10 +194,15 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
user := coderdtest.CreateFirstUser(t, client.Client)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
// Use a member with agents-access instead of the owner to
// verify least-privilege access.
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
@@ -208,7 +213,7 @@ func TestPostChats(t *testing.T) {
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, chat.ID)
require.Equal(t, user.UserID, chat.OwnerID)
require.Equal(t, member.ID, chat.OwnerID)
require.Equal(t, modelConfig.ID, chat.LastModelConfigID)
require.Equal(t, "hello from chats route tests", chat.Title)
require.Equal(t, codersdk.ChatStatusPending, chat.Status)
@@ -218,9 +223,9 @@ func TestPostChats(t *testing.T) {
require.NotNil(t, chat.RootChatID)
require.Equal(t, chat.ID, *chat.RootChatID)
chatResult, err := client.GetChat(ctx, chat.ID)
chatResult, err := memberClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
messagesResult, err := memberClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Equal(t, chat.ID, chatResult.ID)
@@ -240,6 +245,29 @@ func TestPostChats(t *testing.T) {
require.True(t, foundUserMessage)
})
t.Run("MemberWithoutAgentsAccess", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Member without agents-access should be denied.
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "this should fail",
},
},
})
requireSDKError(t, err, http.StatusForbidden)
})
t.Run("HidesSystemPromptMessages", func(t *testing.T) {
t.Parallel()
@@ -271,7 +299,7 @@ func TestPostChats(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
@@ -307,6 +335,7 @@ func TestPostChats(t *testing.T) {
adminClient.Client,
firstUser.OrganizationID,
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID),
rbac.RoleAgentsAccess(),
)
orgAdminClient := codersdk.NewExperimentalClient(orgAdminClientRaw)
@@ -518,7 +547,7 @@ func TestListChats(t *testing.T) {
})
require.NoError(t, err)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
memberDBChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: member.ID,
@@ -586,6 +615,32 @@ func TestListChats(t *testing.T) {
require.Equal(t, memberChats[0].ID, memberChats[0].DiffStatus.ChatID)
})
t.Run("MemberWithoutAgentsAccess", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
// Create a member without agents-access and insert a chat
// owned by them via system context. This verifies the
// RBAC filter actually excludes results rather than
// returning empty because no chats exist.
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat",
})
require.NoError(t, err)
chats, err := memberClient.ListChats(ctx, nil)
require.NoError(t, err)
require.Empty(t, chats)
})
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
@@ -1958,7 +2013,7 @@ func TestGetChat(t *testing.T) {
})
require.NoError(t, err)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, err = otherClient.GetChat(ctx, createdChat.ID)
requireSDKError(t, err, http.StatusNotFound)
@@ -3530,7 +3585,7 @@ func TestRegenerateChatTitle(t *testing.T) {
})
require.NoError(t, err)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, err = otherClient.RegenerateChatTitle(ctx, createdChat.ID)
requireSDKError(t, err, http.StatusNotFound)
@@ -3855,7 +3910,7 @@ func TestGetChatDiffStatus(t *testing.T) {
})
require.NoError(t, err)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, err = otherClient.GetChat(ctx, createdChat.ID)
requireSDKError(t, err, http.StatusNotFound)
@@ -4088,7 +4143,7 @@ func TestGetChatDiffContents(t *testing.T) {
})
require.NoError(t, err)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, err = otherClient.GetChatDiffContents(ctx, createdChat.ID)
requireSDKError(t, err, http.StatusNotFound)
@@ -4884,7 +4939,7 @@ func TestGetChatFile(t *testing.T) {
uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data))
require.NoError(t, err)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
_, _, err = otherClient.GetChatFile(ctx, uploaded.ID)
requireSDKError(t, err, http.StatusNotFound)
+38 -4
View File
@@ -21,6 +21,7 @@ const (
templateAdmin string = "template-admin"
userAdmin string = "user-admin"
auditor string = "auditor"
agentsAccess string = "agents-access"
// customSiteRole is a placeholder for all custom site roles.
// This is used for what roles can assign other roles.
// TODO: Make this more dynamic to allow other roles to grant.
@@ -142,6 +143,7 @@ func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAd
func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} }
func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} }
func RoleAuditor() RoleIdentifier { return RoleIdentifier{Name: auditor} }
func RoleAgentsAccess() RoleIdentifier { return RoleIdentifier{Name: agentsAccess} }
func RoleOrgAdmin() string {
return orgAdmin
@@ -316,7 +318,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
denyPermissions...,
),
User: append(
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception),
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat),
Permissions(map[string][]policy.Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
@@ -402,6 +404,21 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
agentsAccessRole := Role{
Identifier: RoleAgentsAccess(),
DisplayName: "Coder Agents User",
Site: []Permission{},
User: Permissions(map[string][]policy.Action{
ResourceChat.Type: {
policy.ActionCreate,
policy.ActionRead,
policy.ActionUpdate,
policy.ActionDelete,
},
}),
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
builtInRoles = map[string]func(orgID uuid.UUID) Role{
// admin grants all actions to all resources.
owner: func(_ uuid.UUID) Role {
@@ -428,6 +445,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
return userAdminRole
},
// agentsAccess grants all actions on chat resources owned
// by the user. Without this role, members cannot create
// or interact with chats.
agentsAccess: func(_ uuid.UUID) Role {
return agentsAccessRole
},
// orgAdmin returns a role with all actions allows in a given
// organization scope.
orgAdmin: func(organizationID uuid.UUID) Role {
@@ -600,6 +624,7 @@ var assignRoles = map[string]map[string]bool{
userAdmin: true,
customSiteRole: true,
customOrganizationRole: true,
agentsAccess: true,
},
owner: {
owner: true,
@@ -615,10 +640,12 @@ var assignRoles = map[string]map[string]bool{
userAdmin: true,
customSiteRole: true,
customOrganizationRole: true,
agentsAccess: true,
},
userAdmin: {
member: true,
orgMember: true,
member: true,
orgMember: true,
agentsAccess: true,
},
orgAdmin: {
orgAdmin: true,
@@ -854,13 +881,20 @@ func SiteBuiltInRoles() []Role {
for _, roleF := range builtInRoles {
// Must provide some non-nil uuid to filter out org roles.
role := roleF(uuid.New())
if !role.Identifier.IsOrgRole() {
if !role.Identifier.IsOrgRole() && role.Identifier != RoleAgentsAccess() {
roles = append(roles, role)
}
}
return roles
}
// AgentsAccessRole returns the agents-access role for use by callers
// that need to include it conditionally (e.g. when the agents
// experiment is enabled).
func AgentsAccessRole() Role {
return builtInRoles[agentsAccess](uuid.Nil)
}
// ChangeRoleSet is a helper function that finds the difference of 2 sets of
// roles. When setting a user's new roles, it is equivalent to adding and
// removing roles. This set determines the changes, so that the appropriate
+87 -82
View File
@@ -49,6 +49,11 @@ func TestBuiltInRoles(t *testing.T) {
require.NoError(t, r.Valid(), "invalid role")
})
}
t.Run("agents-access", func(t *testing.T) {
t.Parallel()
require.NoError(t, rbac.AgentsAccessRole().Valid(), "invalid role")
})
}
// permissionGranted checks whether a permission list contains a
@@ -199,6 +204,7 @@ func TestRolePermissions(t *testing.T) {
orgUserAdmin := authSubject{Name: "org_user_admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgUserAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
orgTemplateAdmin := authSubject{Name: "org_template_admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgTemplateAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
orgAdminBanWorkspace := authSubject{Name: "org_admin_workspace_ban", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
agentsAccessUser := authSubject{Name: "chat_access", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAgentsAccess()}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
setOrgNotMe := authSubjectSet{orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin}
otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(otherOrg)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
@@ -210,7 +216,7 @@ func TestRolePermissions(t *testing.T) {
// requiredSubjects are required to be asserted in each test case. This is
// to make sure one is not forgotten.
requiredSubjects := []authSubject{
memberMe, owner,
memberMe, owner, agentsAccessUser,
orgAdmin, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
}
@@ -233,7 +239,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceUserObject(currentUser),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe, templateAdmin, userAdmin, orgUserAdmin, otherOrgAdmin, otherOrgUserAdmin, orgAdmin},
true: {owner, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgUserAdmin, otherOrgAdmin, otherOrgUserAdmin, orgAdmin},
false: {
orgTemplateAdmin, orgAuditor,
otherOrgAuditor, otherOrgTemplateAdmin,
@@ -246,7 +252,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceUser,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin},
},
},
{
@@ -256,7 +262,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, orgAdminBanWorkspace},
false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin},
},
},
{
@@ -266,7 +272,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgAdminBanWorkspace},
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
@@ -276,7 +282,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
},
},
{
@@ -286,7 +292,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(policy.WildcardSymbol),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin},
},
},
{
@@ -296,7 +302,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -306,7 +312,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -315,7 +321,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
},
},
{
@@ -324,7 +330,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgAdminBanWorkspace},
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
@@ -337,7 +343,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgAdminBanWorkspace},
false: {
memberMe, setOtherOrg,
memberMe, agentsAccessUser, setOtherOrg,
templateAdmin, userAdmin,
orgTemplateAdmin, orgUserAdmin, orgAuditor,
},
@@ -354,7 +360,7 @@ func TestRolePermissions(t *testing.T) {
true: {},
false: {
orgAdmin, owner, setOtherOrg,
userAdmin, memberMe,
userAdmin, memberMe, agentsAccessUser,
templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor,
orgAdminBanWorkspace,
},
@@ -366,7 +372,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceTemplate.WithID(templateID).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin},
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, userAdmin},
},
},
{
@@ -375,7 +381,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceTemplate.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAuditor, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgUserAdmin, memberMe, userAdmin},
false: {setOtherOrg, orgUserAdmin, memberMe, agentsAccessUser, userAdmin},
},
},
{
@@ -386,7 +392,7 @@ func TestRolePermissions(t *testing.T) {
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, agentsAccessUser, userAdmin},
},
},
{
@@ -397,7 +403,7 @@ func TestRolePermissions(t *testing.T) {
true: {owner, templateAdmin},
// Org template admins can only read org scoped files.
// File scope is currently not org scoped :cry:
false: {setOtherOrg, orgTemplateAdmin, orgAdmin, memberMe, userAdmin, orgAuditor, orgUserAdmin},
false: {setOtherOrg, orgTemplateAdmin, orgAdmin, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin},
},
},
{
@@ -405,7 +411,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead},
Resource: rbac.ResourceFile.WithID(fileID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe, templateAdmin},
true: {owner, memberMe, agentsAccessUser, templateAdmin},
false: {setOtherOrg, setOrgNotMe, userAdmin},
},
},
@@ -415,7 +421,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOrganization,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -424,7 +430,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, orgAuditor, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -433,7 +439,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, auditor, orgAuditor, userAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe},
false: {setOtherOrg, memberMe, agentsAccessUser},
},
},
{
@@ -442,7 +448,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceAssignOrgRole,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, userAdmin, memberMe, templateAdmin},
false: {setOtherOrg, setOrgNotMe, userAdmin, memberMe, agentsAccessUser, templateAdmin},
},
},
{
@@ -451,7 +457,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceAssignRole,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin},
},
},
{
@@ -459,7 +465,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceAssignRole,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {setOtherOrg, setOrgNotMe, owner, memberMe, templateAdmin, userAdmin},
true: {setOtherOrg, setOrgNotMe, owner, memberMe, agentsAccessUser, templateAdmin, userAdmin},
false: {},
},
},
@@ -469,7 +475,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe, templateAdmin, orgTemplateAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgAuditor},
},
},
{
@@ -478,7 +484,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -487,7 +493,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgUserAdmin, userAdmin, templateAdmin},
false: {setOtherOrg, memberMe, orgAuditor, orgTemplateAdmin},
false: {setOtherOrg, memberMe, agentsAccessUser, orgAuditor, orgTemplateAdmin},
},
},
{
@@ -495,7 +501,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete, policy.ActionUpdate},
Resource: rbac.ResourceApiKey.WithID(apiKeyID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe},
true: {owner, memberMe, agentsAccessUser},
false: {setOtherOrg, setOrgNotMe, templateAdmin, userAdmin},
},
},
@@ -507,7 +513,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceInboxNotification.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe},
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe, agentsAccessUser},
},
},
{
@@ -515,7 +521,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionReadPersonal, policy.ActionUpdatePersonal},
Resource: rbac.ResourceUserObject(currentUser),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe, userAdmin},
true: {owner, memberMe, agentsAccessUser, userAdmin},
false: {setOtherOrg, setOrgNotMe, templateAdmin},
},
},
@@ -525,7 +531,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
false: {setOtherOrg, orgTemplateAdmin, orgAuditor, memberMe, templateAdmin},
false: {setOtherOrg, orgTemplateAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin},
},
},
{
@@ -534,7 +540,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin},
false: {memberMe, setOtherOrg},
false: {memberMe, agentsAccessUser, setOtherOrg},
},
},
{
@@ -547,7 +553,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin, orgAuditor},
false: {setOtherOrg, memberMe, userAdmin},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin},
},
},
{
@@ -560,7 +566,7 @@ func TestRolePermissions(t *testing.T) {
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe, templateAdmin, orgTemplateAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgAuditor},
},
},
{
@@ -573,7 +579,7 @@ func TestRolePermissions(t *testing.T) {
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, memberMe},
false: {setOtherOrg, memberMe, agentsAccessUser},
},
},
{
@@ -582,7 +588,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceGroupMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe},
false: {setOtherOrg, memberMe, agentsAccessUser},
},
},
{
@@ -591,7 +597,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceGroupMember.WithID(adminID).InOrg(orgID).WithOwner(adminID.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe},
false: {setOtherOrg, memberMe, agentsAccessUser},
},
},
{
@@ -600,7 +606,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {orgAdmin, owner},
false: {setOtherOrg, userAdmin, memberMe, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
@@ -609,7 +615,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {},
false: {setOtherOrg, setOrgNotMe, memberMe, userAdmin, owner, templateAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, userAdmin, owner, templateAdmin},
},
},
{
@@ -618,7 +624,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, agentsAccessUser, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
@@ -627,7 +633,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourcePrebuiltWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(database.PrebuildsSystemUserID.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, userAdmin, memberMe, orgUserAdmin, orgAuditor},
false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, orgUserAdmin, orgAuditor},
},
},
{
@@ -636,7 +642,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceTask.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, agentsAccessUser, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
// Some admin style resources
@@ -646,7 +652,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceLicense,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -655,7 +661,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceDeploymentStats,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -664,7 +670,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceDeploymentConfig,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -673,7 +679,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceDebugInfo,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -682,7 +688,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceReplicas,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -691,7 +697,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceTailnetCoordinator,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -700,7 +706,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceAuditLog,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -709,7 +715,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, agentsAccessUser, userAdmin},
},
},
{
@@ -718,7 +724,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin},
false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin},
},
},
{
@@ -727,7 +733,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceProvisionerDaemon.WithOwner(currentUser.String()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgTemplateAdmin, orgAdmin},
false: {setOtherOrg, memberMe, userAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgUserAdmin, orgAuditor},
},
},
{
@@ -736,7 +742,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceProvisionerJobs.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgTemplateAdmin, orgAdmin},
false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgUserAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgUserAdmin, orgAuditor},
},
},
{
@@ -745,7 +751,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceSystem,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -754,7 +760,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOauth2App,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -762,7 +768,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceOauth2App,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
true: {owner, setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
false: {},
},
},
@@ -772,7 +778,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOauth2AppSecret,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -781,7 +787,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceOauth2AppCodeToken,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -790,7 +796,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceWorkspaceProxy,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -798,7 +804,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceWorkspaceProxy,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
true: {owner, setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
false: {},
},
},
@@ -809,7 +815,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
Resource: rbac.ResourceNotificationPreference.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {memberMe, owner},
true: {memberMe, agentsAccessUser, owner},
false: {
userAdmin, orgUserAdmin, templateAdmin,
orgAuditor, orgTemplateAdmin,
@@ -826,7 +832,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe, userAdmin, orgUserAdmin, templateAdmin,
memberMe, agentsAccessUser, userAdmin, orgUserAdmin, templateAdmin,
orgAuditor, orgTemplateAdmin,
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
orgAdmin, otherOrgAdmin,
@@ -840,7 +846,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe,
memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
@@ -858,7 +864,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe, templateAdmin, orgUserAdmin, userAdmin,
memberMe, agentsAccessUser, templateAdmin, orgUserAdmin, userAdmin,
orgAdmin, orgAuditor, orgTemplateAdmin,
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
otherOrgAdmin,
@@ -871,7 +877,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
Resource: rbac.ResourceWebpushSubscription.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe},
true: {owner, memberMe, agentsAccessUser},
false: {orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin},
},
},
@@ -883,7 +889,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, userAdmin, orgAdmin, otherOrgAdmin, orgUserAdmin, otherOrgUserAdmin},
false: {
memberMe, templateAdmin,
memberMe, agentsAccessUser, templateAdmin,
orgTemplateAdmin, orgAuditor,
otherOrgAuditor, otherOrgTemplateAdmin,
},
@@ -896,7 +902,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, orgAdmin, otherOrgAdmin},
false: {
userAdmin, memberMe,
userAdmin, memberMe, agentsAccessUser,
orgAuditor, orgUserAdmin,
otherOrgAuditor, otherOrgUserAdmin,
},
@@ -909,7 +915,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, otherOrgAdmin},
false: {
memberMe, userAdmin, templateAdmin,
memberMe, agentsAccessUser, userAdmin, templateAdmin,
orgAuditor, orgUserAdmin, orgTemplateAdmin,
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
@@ -921,7 +927,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceCryptoKey,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -932,7 +938,7 @@ func TestRolePermissions(t *testing.T) {
true: {owner, orgAdmin, orgUserAdmin, userAdmin},
false: {
otherOrgAdmin,
memberMe, templateAdmin,
memberMe, agentsAccessUser, templateAdmin,
orgAuditor, orgTemplateAdmin,
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
@@ -947,7 +953,7 @@ func TestRolePermissions(t *testing.T) {
false: {
orgAdmin, orgUserAdmin,
otherOrgAdmin,
memberMe, templateAdmin,
memberMe, agentsAccessUser, templateAdmin,
orgAuditor, orgTemplateAdmin,
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
},
@@ -960,7 +966,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe,
memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
@@ -975,7 +981,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe,
memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
@@ -989,7 +995,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceConnectionLog,
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
// Only the user themselves can access their own secrets — no one else.
@@ -998,7 +1004,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceUserSecret.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {memberMe},
true: {memberMe, agentsAccessUser},
false: {
owner, orgAdmin,
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
@@ -1014,7 +1020,7 @@ func TestRolePermissions(t *testing.T) {
true: {},
false: {
owner,
memberMe,
memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
@@ -1028,7 +1034,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate},
Resource: rbac.ResourceAibridgeInterception.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe},
true: {owner, memberMe, agentsAccessUser},
false: {
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
@@ -1045,7 +1051,7 @@ func TestRolePermissions(t *testing.T) {
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, auditor},
false: {
memberMe,
memberMe, agentsAccessUser,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
@@ -1058,7 +1064,7 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceBoundaryUsage,
AuthorizeMap: map[bool][]hasAuthSubjects{
false: {owner, setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
},
},
{
@@ -1066,8 +1072,9 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceChat.WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, memberMe},
true: {owner, agentsAccessUser},
false: {
memberMe,
orgAdmin, otherOrgAdmin,
orgAuditor, otherOrgAuditor,
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
@@ -1076,7 +1083,6 @@ func TestRolePermissions(t *testing.T) {
},
},
}
// Build coverage set from test case definitions statically,
// so we don't need shared mutable state during execution.
// This allows subtests to run in parallel.
@@ -1217,7 +1223,6 @@ func TestListRoles(t *testing.T) {
"user-admin",
},
siteRoleNames)
orgID := uuid.New()
orgRoles := rbac.OrganizationRoles(orgID)
orgRoleNames := make([]string, 0, len(orgRoles))
+11 -1
View File
@@ -5,6 +5,7 @@ import (
"github.com/google/uuid"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
@@ -43,7 +44,16 @@ func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, rbac.SiteBuiltInRoles(), dbCustomRoles))
siteRoles := rbac.SiteBuiltInRoles()
// Include the agents-access role only when the agents
// experiment is enabled or this is a dev build, matching
// the RequireExperimentWithDevBypass gate on chat routes.
if api.Experiments.Enabled(codersdk.ExperimentAgents) || buildinfo.IsDev() {
siteRoles = append(siteRoles, rbac.AgentsAccessRole())
}
httpapi.Write(ctx, rw, http.StatusOK,
assignableRoles(actorRoles.Roles, siteRoles, dbCustomRoles))
}
// assignableOrgRoles returns all org wide roles that can be assigned.
+72 -7
View File
@@ -1244,17 +1244,57 @@ func (p *Server) EditMessage(
return result, nil
}
// ArchiveChat archives a chat and all descendants, then broadcasts a deleted event.
// ArchiveChat archives a chat and all descendants. If the target chat is
// pending or running, it first transitions the chat back to waiting so active
// processing stops before the archive is broadcast.
func (p *Server) ArchiveChat(ctx context.Context, chat database.Chat) error {
if chat.ID == uuid.Nil {
return xerrors.New("chat_id is required")
}
if err := p.db.ArchiveChatByID(ctx, chat.ID); err != nil {
return xerrors.Errorf("archive chat: %w", err)
statusChat := chat
interrupted := false
if err := p.db.InTx(func(tx database.Store) error {
lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("lock chat for archive: %w", err)
}
statusChat = lockedChat
// We do not call setChatWaiting here because it intentionally preserves
// pending chats so queued-message promotion can win. Archiving is a
// harder stop: both pending and running chats must transition to waiting.
if lockedChat.Status == database.ChatStatusPending || lockedChat.Status == database.ChatStatusRunning {
statusChat, err = tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusWaiting,
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if err != nil {
return xerrors.Errorf("set chat waiting before archive: %w", err)
}
interrupted = true
}
if err := tx.ArchiveChatByID(ctx, chat.ID); err != nil {
return xerrors.Errorf("archive chat: %w", err)
}
return nil
}, nil); err != nil {
return err
}
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindDeleted, nil)
if interrupted {
p.publishStatus(chat.ID, statusChat.Status, statusChat.WorkerID)
p.publishChatPubsubEvent(statusChat, coderdpubsub.ChatEventKindStatusChange, nil)
}
statusChat.Archived = true
statusChat.PinOrder = 0
p.publishChatPubsubEvent(statusChat, coderdpubsub.ChatEventKindDeleted, nil)
return nil
}
@@ -3447,7 +3487,25 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
chatCtx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
controlCancel := p.subscribeChatControl(chatCtx, chat.ID, cancel, logger)
// Gate the control subscriber behind a channel that is closed
// after we publish "running" status. This prevents stale
// pubsub notifications (e.g. the "pending" notification from
// SendMessage that triggered this processing) from
// interrupting us before we start work. Due to async
// PostgreSQL NOTIFY delivery, a notification published before
// subscribeChatControl registers its queue can still arrive
// after registration.
controlArmed := make(chan struct{})
gatedCancel := func(cause error) {
select {
case <-controlArmed:
cancel(cause)
default:
logger.Debug(ctx, "ignoring control notification before armed")
}
}
controlCancel := p.subscribeChatControl(chatCtx, chat.ID, gatedCancel, logger)
defer func() {
if controlCancel != nil {
controlCancel()
@@ -3508,6 +3566,12 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
Valid: true,
})
// Arm the control subscriber. Closing the channel is a
// happens-before guarantee in the Go memory model — any
// notification dispatched after this point will correctly
// interrupt processing.
close(controlArmed)
// Determine the final status and last error to set when we're done.
status := database.ChatStatusWaiting
wasInterrupted := false
@@ -3563,9 +3627,10 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
// the worker and let the processor pick it back up.
if latestChat.Status == database.ChatStatusPending {
status = database.ChatStatusPending
} else if status == database.ChatStatusWaiting {
} else if status == database.ChatStatusWaiting && !latestChat.Archived {
// Queued messages were already admitted through SendMessage,
// so auto-promotion only preserves FIFO order here.
// so auto-promotion only preserves FIFO order here. Archived
// chats skip promotion so archiving behaves like a hard stop.
var promoteErr error
promotedMessage, remainingQueuedMessages, shouldPublishQueueUpdate, promoteErr = p.tryAutoPromoteQueuedMessage(cleanupCtx, tx, latestChat)
if promoteErr != nil {
+92
View File
@@ -2018,3 +2018,95 @@ func chatMessageWithParts(parts []codersdk.ChatMessagePart) database.ChatMessage
Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true},
}
}
// TestProcessChat_IgnoresStaleControlNotification verifies that
// processChat is not interrupted by a "pending" notification
// published before processing begins. This is the race that caused
// TestOpenAIReasoningWithWebSearchRoundTripStoreFalse to flake:
// SendMessage publishes "pending" via PostgreSQL NOTIFY, and due
// to async delivery the notification can arrive at the control
// subscriber after it registers but before the processor publishes
// "running".
func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
ps := dbpubsub.NewInMemory()
clock := quartz.NewMock(t)
chatID := uuid.New()
workerID := uuid.New()
server := &Server{
db: db,
logger: logger,
pubsub: ps,
clock: clock,
workerID: workerID,
chatHeartbeatInterval: time.Minute,
configCache: newChatConfigCache(ctx, db, clock),
}
// Publish a stale "pending" notification on the control channel
// BEFORE processChat subscribes. In production this is the
// notification from SendMessage that triggered the processing.
staleNotify, err := json.Marshal(coderdpubsub.ChatStreamNotifyMessage{
Status: string(database.ChatStatusPending),
})
require.NoError(t, err)
err = ps.Publish(coderdpubsub.ChatStreamNotifyChannel(chatID), staleNotify)
require.NoError(t, err)
// Track which status processChat writes during cleanup.
var finalStatus database.ChatStatus
cleanupDone := make(chan struct{})
// The deferred cleanup in processChat runs a transaction.
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
func(fn func(database.Store) error, _ *database.TxOptions) error {
return fn(db)
},
)
db.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(
database.Chat{ID: chatID, Status: database.ChatStatusRunning, WorkerID: uuid.NullUUID{UUID: workerID, Valid: true}}, nil,
)
db.EXPECT().UpdateChatStatus(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, params database.UpdateChatStatusParams) (database.Chat, error) {
finalStatus = params.Status
close(cleanupDone)
return database.Chat{ID: chatID, Status: params.Status}, nil
},
)
// resolveChatModel fails immediately — that's fine, we only
// need processChat to get past initialization without being
// interrupted by the stale notification.
db.EXPECT().GetChatModelConfigByID(gomock.Any(), gomock.Any()).Return(
database.ChatModelConfig{}, xerrors.New("no model configured"),
).AnyTimes()
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return(nil, nil).AnyTimes()
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return(nil, nil).AnyTimes()
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(
database.ChatUsageLimitConfig{}, sql.ErrNoRows,
).AnyTimes()
db.EXPECT().GetChatMessagesForPromptByChatID(gomock.Any(), chatID).Return(nil, nil).AnyTimes()
chat := database.Chat{ID: chatID, LastModelConfigID: uuid.New()}
go server.processChat(ctx, chat)
select {
case <-cleanupDone:
case <-ctx.Done():
t.Fatal("processChat did not complete")
}
// If the stale notification interrupted us, status would be
// "waiting" (the ErrInterrupted path). Since the gate blocked
// it, processChat reached runChat, which failed on model
// resolution → status is "error".
require.Equal(t, database.ChatStatusError, finalStatus,
"processChat should have reached runChat (error), not been interrupted (waiting)")
}
+174
View File
@@ -297,6 +297,180 @@ func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
require.False(t, fromDB.WorkerID.Valid)
}
func TestArchiveChatMovesPendingChatToWaiting(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
replica := newTestServer(t, db, ps, uuid.New())
ctx := testutil.Context(t, testutil.WaitLong)
user, model := seedChatDependencies(ctx, t, db)
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "archive-pending",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusPending,
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
require.NoError(t, err)
err = replica.ArchiveChat(ctx, chat)
require.NoError(t, err)
fromDB, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusWaiting, fromDB.Status)
require.False(t, fromDB.WorkerID.Valid)
require.False(t, fromDB.StartedAt.Valid)
require.False(t, fromDB.HeartbeatAt.Valid)
require.True(t, fromDB.Archived)
require.Zero(t, fromDB.PinOrder)
}
func TestArchiveChatInterruptsActiveProcessing(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
streamStarted := make(chan struct{})
streamCanceled := make(chan struct{})
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
chunks := make(chan chattest.OpenAIChunk, 1)
go func() {
defer close(chunks)
chunks <- chattest.OpenAITextChunks("partial")[0]
select {
case <-streamStarted:
default:
close(streamStarted)
}
<-req.Context().Done()
select {
case <-streamCanceled:
default:
close(streamCanceled)
}
}()
return chattest.OpenAIResponse{StreamingChunks: chunks}
})
server := newActiveTestServer(t, db, ps)
user, model := seedChatDependencies(ctx, t, db)
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "archive-interrupt",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
fromDB, dbErr := db.GetChatByID(ctx, chat.ID)
if dbErr != nil {
return false
}
return fromDB.Status == database.ChatStatusRunning && fromDB.WorkerID.Valid
}, testutil.IntervalFast)
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
select {
case <-streamStarted:
return true
default:
return false
}
}, testutil.IntervalFast)
_, events, cancel, ok := server.Subscribe(ctx, chat.ID, nil, 0)
require.True(t, ok)
defer cancel()
queuedResult, err := server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("queued")},
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
})
require.NoError(t, err)
require.True(t, queuedResult.Queued)
require.NotNil(t, queuedResult.QueuedMessage)
err = server.ArchiveChat(ctx, chat)
require.NoError(t, err)
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
select {
case <-streamCanceled:
return true
default:
return false
}
}, testutil.IntervalFast)
gotWaitingStatus := false
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
for {
select {
case ev := <-events:
if ev.Type == codersdk.ChatStreamEventTypeStatus &&
ev.Status != nil &&
ev.Status.Status == codersdk.ChatStatusWaiting {
gotWaitingStatus = true
return true
}
default:
return gotWaitingStatus
}
}
}, testutil.IntervalFast)
require.True(t, gotWaitingStatus, "expected a waiting status event after archive")
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
fromDB, dbErr := db.GetChatByID(ctx, chat.ID)
if dbErr != nil {
return false
}
return fromDB.Archived &&
fromDB.Status == database.ChatStatusWaiting &&
!fromDB.WorkerID.Valid &&
!fromDB.StartedAt.Valid &&
!fromDB.HeartbeatAt.Valid
}, testutil.IntervalFast)
queuedMessages, err := db.GetChatQueuedMessages(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, queuedMessages, 1)
require.Equal(t, queuedResult.QueuedMessage.ID, queuedMessages[0].ID)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
AfterID: 0,
})
require.NoError(t, err)
userMessages := 0
for _, msg := range messages {
if msg.Role == database.ChatMessageRoleUser {
userMessages++
}
}
require.Equal(t, 1, userMessages, "expected queued message to stay queued after archive")
}
func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) {
t.Parallel()
-313
View File
@@ -1,24 +1,14 @@
package chatd_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -597,306 +587,3 @@ func partTypeSet(parts []codersdk.ChatMessagePart) map[codersdk.ChatMessagePartT
}
return set
}
type openAIStoreMode string
const (
openAIStoreModeTrue openAIStoreMode = "store_true"
openAIStoreModeFalse openAIStoreMode = "store_false"
)
func TestOpenAIReasoningWithWebSearchRoundTrip(t *testing.T) {
t.Parallel()
runOpenAIReasoningWithWebSearchRoundTripTest(t, openAIStoreModeTrue)
}
func TestOpenAIReasoningWithWebSearchRoundTripStoreFalse(t *testing.T) {
t.Parallel()
runOpenAIReasoningWithWebSearchRoundTripTest(t, openAIStoreModeFalse)
}
func runOpenAIReasoningWithWebSearchRoundTripTest(t *testing.T, storeMode openAIStoreMode) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
store := storeMode == openAIStoreModeTrue
type capturedOpenAIRequest struct {
Stream bool `json:"stream,omitempty"`
Store *bool `json:"store,omitempty"`
PreviousResponseID *string `json:"previous_response_id,omitempty"`
Prompt []interface{} `json:"input,omitempty"`
}
var (
streamRequestCount atomic.Int32
firstReq *capturedOpenAIRequest
secondReq *capturedOpenAIRequest
mu sync.Mutex
)
upstreamOpenAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("reasoning + web search title")
}
switch req.Header.Get("X-Request-Ordinal") {
case "1":
return chattest.OpenAIResponse{
ResponseID: "resp_first_test",
StreamingChunks: chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("Here is what I found.")...,
).StreamingChunks,
Reasoning: &chattest.OpenAIReasoningItem{
Summary: "thinking about the question",
EncryptedContent: "encrypted_data_here",
},
WebSearch: &chattest.OpenAIWebSearchCall{
Query: "latest AI news",
},
}
default:
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("Follow-up answer.")...,
)
}
})
captureServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read OpenAI request body: %v", err)
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
_ = r.Body.Close()
if r.URL.Path == "/responses" {
var captured capturedOpenAIRequest
if err := json.Unmarshal(body, &captured); err != nil {
t.Errorf("decode OpenAI request body: %v", err)
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if captured.Stream {
requestCount := streamRequestCount.Add(1)
r.Header.Set("X-Request-Ordinal", strconv.Itoa(int(requestCount)))
mu.Lock()
switch requestCount {
case 1:
firstReq = &captured
default:
secondReq = &captured
}
mu.Unlock()
}
}
upstreamReq, err := http.NewRequestWithContext(
r.Context(),
r.Method,
upstreamOpenAIURL+r.URL.RequestURI(),
bytes.NewReader(body),
)
if err != nil {
t.Errorf("create upstream OpenAI request: %v", err)
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
upstreamReq.Header = r.Header.Clone()
resp, err := http.DefaultClient.Do(upstreamReq)
if err != nil {
t.Errorf("forward OpenAI request: %v", err)
http.Error(rw, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
rw.Header().Add(key, value)
}
}
rw.WriteHeader(resp.StatusCode)
if _, err := io.Copy(rw, resp.Body); err != nil {
t.Errorf("copy OpenAI response body: %v", err)
}
}))
t.Cleanup(captureServer.Close)
openAIURL := captureServer.URL
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
})
_ = coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "openai",
APIKey: "test-api-key",
BaseURL: openAIURL,
})
require.NoError(t, err)
contextLimit := int64(200000)
isDefault := true
reasoningEffort := "medium"
reasoningSummary := "auto"
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: "openai",
Model: "o4-mini",
ContextLimit: &contextLimit,
IsDefault: &isDefault,
ModelConfig: &codersdk.ChatModelCallConfig{
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
Store: ptr.Ref(store),
ReasoningEffort: &reasoningEffort,
ReasoningSummary: &reasoningSummary,
WebSearchEnabled: ptr.Ref(true),
},
},
},
})
require.NoError(t, err)
t.Logf("Creating chat with reasoning + web search query (store=%t)...", store)
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "Search for the latest AI news and summarize it briefly.",
}},
})
require.NoError(t, err)
events, closer, err := expClient.StreamChat(ctx, chat.ID, nil)
require.NoError(t, err)
defer closer.Close()
waitForChatDone(ctx, t, events, "step 1")
chatData, err := expClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
chatMsgs, err := expClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusWaiting, chatData.Status,
"chat should be in waiting status after step 1")
assistantMsg := findAssistantWithText(t, chatMsgs.Messages)
require.NotNil(t, assistantMsg,
"expected an assistant message with text content after step 1")
partTypes := partTypeSet(assistantMsg.Content)
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeReasoning,
"assistant message should contain reasoning parts")
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeToolCall,
"assistant message should contain a provider-executed web search tool call")
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeToolResult,
"assistant message should contain a provider-executed web search tool result")
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeText,
"assistant message should contain a text part")
var foundReasoning, foundWebSearchCall, foundText bool
for _, part := range assistantMsg.Content {
switch part.Type {
case codersdk.ChatMessagePartTypeReasoning:
// fantasy emits a leading newline when the reasoning summary part is
// added, so match the persisted summary text after trimming whitespace.
if strings.TrimSpace(part.Text) == "thinking about the question" {
foundReasoning = true
}
case codersdk.ChatMessagePartTypeToolCall:
if part.ToolName == "web_search" {
require.True(t, part.ProviderExecuted,
"web search tool-call should be marked provider-executed")
foundWebSearchCall = true
}
case codersdk.ChatMessagePartTypeText:
if part.Text == "Here is what I found." {
foundText = true
}
}
}
require.True(t, foundReasoning, "expected reasoning summary text to be persisted")
require.True(t, foundWebSearchCall, "expected persisted web_search tool call")
require.True(t, foundText, "expected streamed assistant text to be persisted")
t.Log("Sending follow-up message...")
_, err = expClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "What is the follow-up takeaway?",
}},
})
if !store && err != nil {
require.NotContains(t, err.Error(),
"Items are not persisted when store is set to false.",
"follow-up should reconstruct store=false responses without stale provider item IDs")
}
require.NoError(t, err)
events2, closer2, err := expClient.StreamChat(ctx, chat.ID, nil)
require.NoError(t, err)
defer closer2.Close()
waitForChatDone(ctx, t, events2, "step 2")
chatData2, err := expClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
chatMsgs2, err := expClient.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Equal(t, codersdk.ChatStatusWaiting, chatData2.Status,
"chat should be in waiting status after step 2")
require.Greater(t, len(chatMsgs2.Messages), len(chatMsgs.Messages),
"follow-up should have added more messages")
require.NotNil(t, findLastAssistantWithText(t, chatMsgs2.Messages),
"expected an assistant message with text after the follow-up")
require.Equal(t, int32(2), streamRequestCount.Load(),
"expected exactly two streamed OpenAI responses")
mu.Lock()
defer mu.Unlock()
require.NotNil(t, firstReq, "expected first streaming request to be captured")
if store {
require.NotNil(t, firstReq.Store, "first request should have store field")
require.True(t, *firstReq.Store, "store should be true")
} else if firstReq.Store != nil {
require.False(t, *firstReq.Store, "store should be false")
}
require.NotNil(t, secondReq, "expected second streaming request to be captured")
foundAssistantReplay := false
for _, item := range secondReq.Prompt {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
role, _ := m["role"].(string)
if role == "assistant" {
foundAssistantReplay = true
}
if store {
require.NotEqual(t, "assistant", role,
"store=true chain-mode prompt should not replay assistant messages")
require.NotEqual(t, "tool", role,
"store=true chain-mode prompt should not replay tool messages")
}
}
if store {
require.NotNil(t, secondReq.PreviousResponseID,
"store=true follow-up should set previous_response_id")
require.Equal(t, "resp_first_test", *secondReq.PreviousResponseID,
"previous_response_id should match the first response's ID")
} else {
if secondReq.PreviousResponseID != nil {
require.Empty(t, *secondReq.PreviousResponseID,
"store=false follow-up should not set previous_response_id")
}
require.True(t, foundAssistantReplay,
"store=false follow-up should replay prior assistant history")
}
}
+11 -9
View File
@@ -3923,15 +3923,17 @@ Write out the current server config as YAML to stdout.`,
YAML: "key_file",
},
{
Name: "AI Bridge Proxy Domain Allowlist",
Description: "Comma-separated list of AI provider domains for which HTTPS traffic will be decrypted and routed through AI Bridge. Requests to other domains will be tunneled directly without decryption. Supported domains: api.anthropic.com, api.openai.com, api.individual.githubcopilot.com.",
Flag: "aibridge-proxy-domain-allowlist",
Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST",
Value: &c.AI.BridgeProxyConfig.DomainAllowlist,
Default: "api.anthropic.com,api.openai.com,api.individual.githubcopilot.com",
Hidden: true,
Group: &deploymentGroupAIBridgeProxy,
YAML: "domain_allowlist",
Name: "AI Bridge Proxy Domain Allowlist",
Description: "Comma-separated list of AI provider domains for which HTTPS traffic will be decrypted and routed through AI Bridge. " +
"Requests to other domains will be tunneled directly without decryption. " +
"Supported domains: api.anthropic.com, api.openai.com, api.individual.githubcopilot.com, api.business.githubcopilot.com, api.enterprise.githubcopilot.com, chatgpt.com.",
Flag: "aibridge-proxy-domain-allowlist",
Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST",
Value: &c.AI.BridgeProxyConfig.DomainAllowlist,
Default: "api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,chatgpt.com",
Hidden: true,
Group: &deploymentGroupAIBridgeProxy,
YAML: "domain_allowlist",
},
{
Name: "AI Bridge Proxy Upstream Proxy",
+2 -1
View File
@@ -1,12 +1,13 @@
package codersdk
// Ideally this roles would be generated from the rbac/roles.go package.
// Ideally these roles would be generated from the rbac/roles.go package.
const (
RoleOwner string = "owner"
RoleMember string = "member"
RoleTemplateAdmin string = "template-admin"
RoleUserAdmin string = "user-admin"
RoleAuditor string = "auditor"
RoleAgentsAccess string = "agents-access"
RoleOrganizationAdmin string = "organization-admin"
RoleOrganizationMember string = "organization-member"
+3
View File
@@ -65,6 +65,9 @@ Once the server restarts with the experiment enabled:
1. Navigate to the **Agents** page in the Coder dashboard.
1. Open **Admin** settings and configure at least one LLM provider and model.
See [Models](./models.md) for detailed setup instructions.
1. Grant the **Coder Agents User** role to users who need to create chats.
Go to **Admin** > **Users**, click the roles icon next to each user,
and enable **Coder Agents User**.
1. Developers can then start a new chat from the Agents page.
## Licensing and availability
+20 -1
View File
@@ -24,6 +24,9 @@ Before you begin, confirm the following:
for the agent to select when provisioning workspaces.
- **Admin access** to the Coder deployment for enabling the experiment and
configuring providers.
- **Coder Agents User role** assigned to each user who needs to create or use chats.
Owners can assign this from **Admin** > **Users**. See
[Grant Coder Agents User](#step-3-grant-coder-agents-user) below.
## Step 1: Enable the experiment
@@ -69,7 +72,23 @@ Detailed instructions for each provider and model option are in the
> Start with a single frontier model to validate your setup before adding
> additional providers.
## Step 3: Start your first chat
## Step 3: Grant Coder Agents User
The **Coder Agents User** role controls which users can create and use chats.
Members do not have Coder Agents User by default.
1. Go to **Admin** > **Users** in the Coder dashboard.
1. Click the roles icon next to the user you want to grant access to.
1. Enable the **Coder Agents User** role and save.
Repeat for each user who needs access. Owners always have full access
and do not need the role.
> [!NOTE]
> Users who created chats before this role was introduced are
> automatically granted the role during upgrade.
## Step 4: Start your first chat
1. Go to the **Agents** page in the Coder dashboard.
1. Select a model from the dropdown (your default will be pre-selected).
+1 -2
View File
@@ -324,8 +324,7 @@
"title": "Workspace Sharing",
"description": "Sharing workspaces",
"path": "./user-guides/shared-workspaces.md",
"icon_path": "./images/icons/generic.svg",
"state": ["beta"]
"icon_path": "./images/icons/generic.svg"
},
{
"title": "Workspace Scheduling",
+2 -2
View File
@@ -11,8 +11,8 @@ RUN cargo install jj-cli typos-cli watchexec-cli
FROM ubuntu:jammy@sha256:ce4a593b4e323dcc3dd728e397e0a866a1bf516a1b7c31d6aa06991baec4f2e0 AS go
# Install Go manually, so that we can control the version
ARG GO_VERSION=1.25.7
ARG GO_CHECKSUM="12e6d6a191091ae27dc31f6efc630e3a3b8ba409baf3573d955b196fdf086005"
ARG GO_VERSION=1.25.8
ARG GO_CHECKSUM="ceb5e041bbc3893846bd1614d76cb4681c91dadee579426cf21a63f2d7e03be6"
# Boring Go is needed to build FIPS-compliant binaries.
RUN apt-get update && \
@@ -776,6 +776,12 @@ func defaultAIBridgeProvider(host string) string {
return aibridge.ProviderOpenAI
case HostCopilot:
return aibridge.ProviderCopilot
case agplaibridge.HostCopilotBusiness:
return agplaibridge.ProviderCopilotBusiness
case agplaibridge.HostCopilotEnterprise:
return agplaibridge.ProviderCopilotEnterprise
case agplaibridge.HostChatGPT:
return agplaibridge.ProviderChatGPT
default:
return ""
}
+20
View File
@@ -10,6 +10,7 @@ import (
"github.com/coder/aibridge"
"github.com/coder/aibridge/config"
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/aibridged"
@@ -37,20 +38,39 @@ func newAIBridgeDaemon(coderAPI *coderd.API) (*aibridged.Server, error) {
// Setup supported providers with circuit breaker config.
providers := []aibridge.Provider{
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
Name: aibridge.ProviderOpenAI,
BaseURL: cfg.OpenAI.BaseURL.String(),
Key: cfg.OpenAI.Key.String(),
CircuitBreaker: cbConfig,
SendActorHeaders: cfg.SendActorHeaders.Value(),
}),
aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
Name: aibridge.ProviderAnthropic,
BaseURL: cfg.Anthropic.BaseURL.String(),
Key: cfg.Anthropic.Key.String(),
CircuitBreaker: cbConfig,
SendActorHeaders: cfg.SendActorHeaders.Value(),
}, getBedrockConfig(cfg.Bedrock)),
aibridge.NewCopilotProvider(aibridge.CopilotConfig{
Name: aibridge.ProviderCopilot,
CircuitBreaker: cbConfig,
}),
aibridge.NewCopilotProvider(aibridge.CopilotConfig{
Name: agplaibridge.ProviderCopilotBusiness,
BaseURL: "https://" + agplaibridge.HostCopilotBusiness,
CircuitBreaker: cbConfig,
}),
aibridge.NewCopilotProvider(aibridge.CopilotConfig{
Name: agplaibridge.ProviderCopilotEnterprise,
BaseURL: "https://" + agplaibridge.HostCopilotEnterprise,
CircuitBreaker: cbConfig,
}),
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
Name: agplaibridge.ProviderChatGPT,
BaseURL: agplaibridge.BaseURLChatGPT,
CircuitBreaker: cbConfig,
SendActorHeaders: cfg.SendActorHeaders.Value(),
}),
}
reg := prometheus.WrapRegistererWithPrefix("coder_aibridged_", coderAPI.PrometheusRegistry)
+297 -1
View File
@@ -440,7 +440,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
},
{
name: "Client/Unknown",
filter: codersdk.AIBridgeListInterceptionsFilter{Client: "Unknown"},
filter: codersdk.AIBridgeListInterceptionsFilter{Client: string(aiblib.ClientUnknown)},
want: []codersdk.AIBridgeInterception{i1SDK},
},
{
@@ -1213,6 +1213,302 @@ func TestAIBridgeListSessions(t *testing.T) {
require.Contains(t, sdkErr.Message, "Invalid pagination limit value.")
require.Empty(t, res.Sessions)
})
t.Run("StartedBeforeFilter", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Session started recently.
recentEndedAt := now.Add(time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &recentEndedAt)
// Session started 2 hours ago.
oldEndedAt := now.Add(-2*time.Hour + time.Minute)
old := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-2 * time.Hour),
}, &oldEndedAt)
// Only the old session should be returned when started_before
// is set to 1 hour ago.
//nolint:gocritic // Owner role is irrelevant; testing filter.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
StartedBefore: now.Add(-time.Hour),
})
require.NoError(t, err)
require.EqualValues(t, 1, res.Count)
require.Len(t, res.Sessions, 1)
require.Equal(t, old.ID.String(), res.Sessions[0].ID)
})
t.Run("NullClientCoalescesToUnknown", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Session with explicit client.
withClientEndedAt := now.Add(time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
Client: sql.NullString{String: "claude-code", Valid: true},
}, &withClientEndedAt)
// Session with NULL client (should COALESCE to ClientUnknown).
nullClientEndedAt := now.Add(-time.Hour + time.Minute)
nullClient := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-time.Hour),
// Client field deliberately omitted (NULL).
}, &nullClientEndedAt)
// Filtering by ClientUnknown should return only the NULL-client
// session.
//nolint:gocritic // Owner role is irrelevant; testing COALESCE.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
Client: string(aiblib.ClientUnknown),
})
require.NoError(t, err)
require.EqualValues(t, 1, res.Count)
require.Len(t, res.Sessions, 1)
require.Equal(t, nullClient.ID.String(), res.Sessions[0].ID)
})
t.Run("MetadataFromFirstInterception", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// First interception (chronologically) carries the expected
// metadata for the session.
i1EndedAt := now.Add(time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
Metadata: json.RawMessage(`{"editor":"vscode"}`),
Client: sql.NullString{String: "claude-code", Valid: true},
ClientSessionID: sql.NullString{String: "meta-session", Valid: true},
}, &i1EndedAt)
// Second interception has different metadata.
i2EndedAt := now.Add(2 * time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(time.Minute),
Metadata: json.RawMessage(`{"editor":"jetbrains"}`),
Client: sql.NullString{String: "claude-code", Valid: true},
ClientSessionID: sql.NullString{String: "meta-session", Valid: true},
}, &i2EndedAt)
//nolint:gocritic // Owner role is irrelevant; testing metadata.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 1)
// Metadata should come from the first interception.
require.Equal(t, "vscode", res.Sessions[0].Metadata["editor"])
})
t.Run("SessionTimestamps", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Two interceptions in the same session with different
// started_at and ended_at values. The session should report
// MIN(started_at) and MAX(ended_at).
i1StartedAt := now
i1EndedAt := now.Add(time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: i1StartedAt,
ClientSessionID: sql.NullString{String: "ts-session", Valid: true},
}, &i1EndedAt)
i2StartedAt := now.Add(2 * time.Minute)
i2EndedAt := now.Add(5 * time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: i2StartedAt,
ClientSessionID: sql.NullString{String: "ts-session", Valid: true},
}, &i2EndedAt)
//nolint:gocritic // Owner role is irrelevant; testing timestamps.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 1)
s := res.Sessions[0]
require.WithinDuration(t, i1StartedAt, s.StartedAt, time.Millisecond,
"session started_at should be MIN of interception started_at values")
require.NotNil(t, s.EndedAt)
require.WithinDuration(t, i2EndedAt, *s.EndedAt, time.Millisecond,
"session ended_at should be MAX of interception ended_at values")
})
t.Run("LastPromptAcrossInterceptions", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Two interceptions in the same session.
i1EndedAt := now.Add(time.Minute)
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
ClientSessionID: sql.NullString{String: "prompt-session", Valid: true},
}, &i1EndedAt)
i2EndedAt := now.Add(3 * time.Minute)
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(2 * time.Minute),
ClientSessionID: sql.NullString{String: "prompt-session", Valid: true},
}, &i2EndedAt)
// Add prompts to both interceptions. The most recent prompt
// overall belongs to the second interception.
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: i1.ID,
Prompt: "early prompt from i1",
CreatedAt: now,
})
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: i2.ID,
Prompt: "latest prompt from i2",
CreatedAt: now.Add(2 * time.Minute),
})
//nolint:gocritic // Owner role is irrelevant; testing lateral join.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 1)
require.NotNil(t, res.Sessions[0].LastPrompt)
require.Equal(t, "latest prompt from i2", *res.Sessions[0].LastPrompt,
"last_prompt should be the most recent prompt across all interceptions in the session")
})
t.Run("CombinedFilters", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
now := dbtime.Now()
// Session A: user1, anthropic, claude-4, started now.
aEndedAt := now.Add(time.Minute)
a := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
Provider: "anthropic",
Model: "claude-4",
StartedAt: now,
}, &aEndedAt)
// Session B: user1, anthropic, gpt-4, started 2h ago.
bEndedAt := now.Add(-2*time.Hour + time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
Provider: "anthropic",
Model: "gpt-4",
StartedAt: now.Add(-2 * time.Hour),
}, &bEndedAt)
// Session C: user2, anthropic, claude-4, started 1h ago.
cEndedAt := now.Add(-time.Hour + time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: user2.ID,
Provider: "anthropic",
Model: "claude-4",
StartedAt: now.Add(-time.Hour),
}, &cEndedAt)
// Combining provider + model + started_after should return
// only session A (user1, anthropic, claude-4, recent).
//nolint:gocritic // Owner role is irrelevant; testing combined filters.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
Provider: "anthropic",
Model: "claude-4",
StartedAfter: now.Add(-30 * time.Minute),
})
require.NoError(t, err)
require.EqualValues(t, 1, res.Count)
require.Len(t, res.Sessions, 1)
require.Equal(t, a.ID.String(), res.Sessions[0].ID)
})
t.Run("CursorPaginationWithTiedStartedAt", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Create 3 standalone sessions all starting at the same time.
// The tie-breaker is session_id DESC.
for range 3 {
endedAt := now.Add(time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &endedAt)
}
// Fetch all to learn the sort order (started_at DESC,
// session_id DESC).
//nolint:gocritic // Owner role is irrelevant; testing cursor.
all, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, all.Sessions, 3)
// Use the first result as cursor. The remaining 2 should be
// returned.
afterID := all.Sessions[0].ID
page, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
Pagination: codersdk.Pagination{Limit: 10},
AfterSessionID: afterID,
})
require.NoError(t, err)
require.Len(t, page.Sessions, 2)
require.Equal(t, all.Sessions[1].ID, page.Sessions[0].ID)
require.Equal(t, all.Sessions[2].ID, page.Sessions[1].ID)
})
t.Run("DefaultLimit", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Create 3 sessions. Without an explicit limit the default of
// 100 should apply and return all 3.
for i := range 3 {
endedAt := now.Add(-time.Duration(i)*time.Hour + time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-time.Duration(i) * time.Hour),
}, &endedAt)
}
// No Pagination.Limit set.
//nolint:gocritic // Owner role is irrelevant; testing default limit.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 3)
require.EqualValues(t, 3, res.Count)
})
}
func TestAIBridgeListClients(t *testing.T) {
+9
View File
@@ -452,7 +452,13 @@ func TestCustomOrganizationRole(t *testing.T) {
func TestListRoles(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentAgents)}
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureExternalProvisionerDaemons: 1,
@@ -487,6 +493,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleAuditor}: false,
{Name: codersdk.RoleTemplateAdmin}: false,
{Name: codersdk.RoleUserAdmin}: false,
{Name: codersdk.RoleAgentsAccess}: false,
}),
},
{
@@ -520,6 +527,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleAuditor}: false,
{Name: codersdk.RoleTemplateAdmin}: false,
{Name: codersdk.RoleUserAdmin}: false,
{Name: codersdk.RoleAgentsAccess}: false,
}),
},
{
@@ -553,6 +561,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleAuditor}: true,
{Name: codersdk.RoleTemplateAdmin}: true,
{Name: codersdk.RoleUserAdmin}: true,
{Name: codersdk.RoleAgentsAccess}: true,
}),
},
{
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/coder/coder/v2
go 1.25.7
go 1.25.8
// Required until a v3 of chroma is created to lazily initialize all XML files.
// None of our dependencies seem to use the registries anyways, so this
@@ -483,7 +483,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566
github.com/coder/aibridge v1.1.1-0.20260331154949-a011104f377d
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab
github.com/coder/preview v1.0.8
+2 -2
View File
@@ -314,8 +314,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566 h1:DK+a7Q9bPpTyq7ePaz81Ihauyp1ilXNhF8MI+7rmZpA=
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
github.com/coder/aibridge v1.1.1-0.20260331154949-a011104f377d h1:yoDGndlvKP6fiKzivG7kYLYs7jDEt2phgGVagDmuAHY=
github.com/coder/aibridge v1.1.1-0.20260331154949-a011104f377d/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab h1:HrlxyTmMQpOHfSKzRU1vf5TxrmV6vL5OiWq+Dvn5qh0=
+4
View File
@@ -118,5 +118,9 @@
"viewOAuth2AppSecrets": {
"object": { "resource_type": "oauth2_app_secret" },
"action": "read"
},
"createChat": {
"object": { "resource_type": "chat", "owner_id": "me" },
"action": "create"
}
}
+8 -1
View File
@@ -571,9 +571,16 @@ func init() {
func (h *Handler) renderPermissions(ctx context.Context, actor rbac.Subject) string {
response := make(codersdk.AuthorizationResponse)
for k, v := range permissionChecks {
// Resolve the "me" sentinel so permission checks
// run against the actual actor, matching the
// API-side handling in coderd/authorize.go.
ownerID := v.Object.OwnerID
if ownerID == codersdk.Me {
ownerID = actor.ID
}
obj := rbac.Object{
ID: v.Object.ResourceID,
Owner: v.Object.OwnerID,
Owner: ownerID,
OrgID: v.Object.OrganizationID,
AnyOrgOwner: v.Object.AnyOrgOwner,
Type: string(v.Object.ResourceType),
+70
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
@@ -31,6 +32,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/site"
@@ -79,6 +81,74 @@ func TestInjection(t *testing.T) {
require.Equal(t, db2sdk.User(user, []uuid.UUID{}), got)
}
func TestRenderPermissionsResolvesMe(t *testing.T) {
t.Parallel()
// GIVEN: a site handler wired to a real RBAC authorizer and a
// template that renders only the SSR permissions JSON.
siteFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("{{ .Permissions }}"),
},
}
db, _ := dbtestutil.NewDB(t)
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
handler, err := site.New(&site.Options{
Telemetry: telemetry.NewNoop(),
Database: db,
SiteFS: siteFS,
Authorizer: authorizer,
})
require.NoError(t, err)
// GIVEN: a user with the agents-access role.
userWithRole := dbgen.User(t, db, database.User{
RBACRoles: []string{"agents-access"},
})
_, tokenWithRole := dbgen.APIKey(t, db, database.APIKey{
UserID: userWithRole.ID,
ExpiresAt: time.Now().Add(time.Hour),
})
// WHEN: the user loads the page.
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, tokenWithRole)
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, r)
require.Equal(t, http.StatusOK, rw.Code)
// THEN: the SSR-rendered permissions include createChat = true
// because the "me" sentinel in permissions.json was resolved to
// the actor's ID, and the agents-access role grants user-scoped
// chat create permission.
var permsWithRole codersdk.AuthorizationResponse
err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithRole)
require.NoError(t, err)
assert.True(t, permsWithRole["createChat"], "user with agents-access role should have createChat = true")
// GIVEN: a user without the agents-access role.
userWithoutRole := dbgen.User(t, db, database.User{})
_, tokenWithoutRole := dbgen.APIKey(t, db, database.APIKey{
UserID: userWithoutRole.ID,
ExpiresAt: time.Now().Add(time.Hour),
})
// WHEN: the user loads the page.
r = httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, tokenWithoutRole)
rw = httptest.NewRecorder()
handler.ServeHTTP(rw, r)
require.Equal(t, http.StatusOK, rw.Code)
// THEN: createChat = false because the member role does not
// grant chat permissions.
var permsWithoutRole codersdk.AuthorizationResponse
err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutRole)
require.NoError(t, err)
assert.False(t, permsWithoutRole["createChat"], "user without agents-access role should have createChat = false")
}
func TestInjectionFailureProducesCleanHTML(t *testing.T) {
t.Parallel()
+1 -8
View File
@@ -1,11 +1,5 @@
import { type AxiosError, type AxiosResponse, isAxiosError } from "axios";
const Language = {
errorsByCode: {
defaultErrorCode: "Invalid value",
},
};
export interface FieldError {
field: string;
detail: string;
@@ -64,8 +58,7 @@ export const mapApiErrorToFieldErrors = (
if (apiErrorResponse.validations) {
for (const error of apiErrorResponse.validations) {
result[error.field] =
error.detail || Language.errorsByCode.defaultErrorCode;
result[error.field] = error.detail || "Invalid value";
}
}
+17 -11
View File
@@ -5925,56 +5925,62 @@ export interface Role {
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleAgentsAccess = "agents-access";
// From codersdk/rbacroles.go
/**
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleAuditor = "auditor";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleMember = "member";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOrganizationAdmin = "organization-admin";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOrganizationAuditor = "organization-auditor";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOrganizationMember = "organization-member";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOrganizationTemplateAdmin = "organization-template-admin";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOrganizationUserAdmin = "organization-user-admin";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOrganizationWorkspaceCreationBan =
"organization-workspace-creation-ban";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleOwner = "owner";
@@ -5993,13 +5999,13 @@ export interface RoleSyncSettings {
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleTemplateAdmin = "template-admin";
// From codersdk/rbacroles.go
/**
* Ideally this roles would be generated from the rbac/roles.go package.
* Ideally these roles would be generated from the rbac/roles.go package.
*/
export const RoleUserAdmin = "user-admin";
@@ -7,12 +7,12 @@ import {
ChartTooltipContent,
} from "#/components/Chart/Chart";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipIconTrigger,
HelpTooltipText,
HelpTooltipTitle,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopover,
HelpPopoverContent,
HelpPopoverIconTrigger,
HelpPopoverText,
HelpPopoverTitle,
} from "#/components/HelpPopover/HelpPopover";
import { formatDate } from "#/utils/time";
const chartConfig = {
@@ -120,18 +120,18 @@ export const ActiveUsersTitle: FC<ActiveUsersTitleProps> = ({ interval }) => {
return (
<div className="flex items-center gap-2">
{interval === "day" ? "Daily" : "Weekly"} Active Users
<HelpTooltip>
<HelpTooltipIconTrigger size="small" />
<HelpTooltipContent>
<HelpTooltipTitle>How do we calculate active users?</HelpTooltipTitle>
<HelpTooltipText>
<HelpPopover>
<HelpPopoverIconTrigger size="small" />
<HelpPopoverContent>
<HelpPopoverTitle>How do we calculate active users?</HelpPopoverTitle>
<HelpPopoverText>
When a connection is initiated to a user&apos;s workspace they are
considered an active user. e.g. apps, web terminal, SSH. This is for
measuring user activity and has no connection to license
consumption.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
</div>
);
};
+1 -1
View File
@@ -37,7 +37,7 @@ export const AvatarCard: FC<AvatarCardProps> = ({
*
* @see {@link https://css-tricks.com/flexbox-truncated-text/}
*/}
<div css={{ marginRight: "auto", minWidth: 0 }}>
<div className="mr-auto min-w-0">
<h3
// Lets users hover over truncated text to see whole thing
title={header}
+1 -6
View File
@@ -75,12 +75,7 @@ export const DeprecatedBadge: React.FC = () => {
export const Badges: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Stack
css={{ margin: "0 0 16px" }}
direction="row"
alignItems="center"
spacing={1}
>
<Stack className="mb-4" direction="row" alignItems="center" spacing={1}>
{children}
</Stack>
);
@@ -1,19 +1,18 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC } from "react";
import { CoderIcon } from "#/components/Icons/CoderIcon";
import { getApplicationName, getLogoURL } from "#/utils/appearance";
import { cn } from "#/utils/cn";
/**
* Enterprise customers can set a custom logo for their Coder application. Use
* the custom logo wherever the Coder logo is used, if a custom one is provided.
*/
export const CustomLogo: FC<{ css?: Interpolation<Theme> }> = (props) => {
export const CustomLogo: FC<{ className?: string }> = ({ className }) => {
const applicationName = getApplicationName();
const logoURL = getLogoURL();
return logoURL ? (
<img
{...props}
alt={applicationName}
src={logoURL}
// This prevent browser to display the ugly error icon if the
@@ -24,10 +23,9 @@ export const CustomLogo: FC<{ css?: Interpolation<Theme> }> = (props) => {
onLoad={(e) => {
e.currentTarget.style.display = "inline";
}}
css={{ maxWidth: 200 }}
className="application-logo"
className={cn("max-w-[200px] application-logo", className)}
/>
) : (
<CoderIcon {...props} className="w-12 h-12" />
<CoderIcon className={cn("w-12 h-12", className)} />
);
};
@@ -78,7 +78,7 @@ export const DeleteDialog: FC<DeleteDialogProps> = ({
<TextField
fullWidth
autoFocus
css={{ marginTop: 24 }}
className="mt-6"
name="confirmation"
autoComplete="off"
id={`${hookId}-confirm`}
@@ -77,12 +77,7 @@ export const DurationField: FC<DurationFieldProps> = (props) => {
return (
<div>
<div
css={{
display: "flex",
gap: 8,
}}
>
<div className="flex gap-2">
<TextField
{...textFieldProps}
fullWidth
+1 -1
View File
@@ -146,7 +146,7 @@ const BaseSkeleton: FC<SkeletonProps> = ({ children, ...skeletonProps }) => {
};
export const MenuSkeleton: FC = () => {
return <BaseSkeleton css={{ minWidth: 200, flexShrink: 0 }} />;
return <BaseSkeleton className="min-w-[200px] shrink-0" />;
};
type FilterProps = {
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
HelpPopover,
HelpPopoverLink,
HelpPopoverLinksGroup,
HelpPopoverText,
HelpPopoverTitle,
} from "./HelpPopover";
const meta: Meta<typeof HelpPopover> = {
title: "components/HelpPopover",
component: HelpPopover,
args: {
children: (
<>
<HelpPopoverTitle>What is a template?</HelpPopoverTitle>
<HelpPopoverText>
A template is a common configuration for your team&apos;s workspaces.
</HelpPopoverText>
<HelpPopoverLinksGroup>
<HelpPopoverLink href="https://github.com/coder/coder/">
Creating a template
</HelpPopoverLink>
<HelpPopoverLink href="https://github.com/coder/coder/">
Updating a template
</HelpPopoverLink>
</HelpPopoverLinksGroup>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof HelpPopover>;
export const Example: Story = {};
@@ -1,32 +1,29 @@
import { CircleHelpIcon, ExternalLinkIcon } from "lucide-react";
import type { FC, HTMLAttributes, PropsWithChildren, ReactNode } from "react";
import {
Tooltip,
TooltipContent,
type TooltipContentProps,
type TooltipProps,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
Popover,
PopoverContent,
type PopoverContentProps,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { cn } from "#/utils/cn";
type Icon = typeof CircleHelpIcon;
type Size = "small" | "medium";
export const HelpTooltipTrigger = TooltipTrigger;
export const HelpPopoverTrigger = PopoverTrigger;
export const HelpTooltipIcon = CircleHelpIcon;
export const HelpPopoverIcon = CircleHelpIcon;
export const HelpTooltip: FC<TooltipProps> = (props) => {
return <Tooltip {...props} />;
};
export const HelpPopover = Popover;
export const HelpTooltipContent: FC<TooltipContentProps> = ({
export const HelpPopoverContent: FC<PopoverContentProps> = ({
className,
...props
}) => {
return (
<TooltipContent
<PopoverContent
side="bottom"
align="start"
collisionPadding={16}
@@ -39,22 +36,23 @@ export const HelpTooltipContent: FC<TooltipContentProps> = ({
);
};
type HelpTooltipIconTriggerProps = React.ComponentPropsWithRef<"button"> & {
type HelpPopoverIconTriggerProps = React.ComponentPropsWithRef<"button"> & {
size?: Size;
hoverEffect?: boolean;
};
export const HelpTooltipIconTrigger: React.FC<HelpTooltipIconTriggerProps> = ({
export const HelpPopoverIconTrigger: React.FC<HelpPopoverIconTriggerProps> = ({
size = "medium",
children = <HelpTooltipIcon />,
children = <HelpPopoverIcon />,
hoverEffect = true,
className,
...buttonProps
}) => {
return (
<HelpTooltipTrigger asChild>
<HelpPopoverTrigger asChild>
<button
{...buttonProps}
type="button"
aria-label="More info"
className={cn(
"flex items-center justify-center px-0 py-1",
@@ -66,11 +64,11 @@ export const HelpTooltipIconTrigger: React.FC<HelpTooltipIconTriggerProps> = ({
>
{children}
</button>
</HelpTooltipTrigger>
</HelpPopoverTrigger>
);
};
export const HelpTooltipTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
export const HelpPopoverTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
children,
className,
...attrs
@@ -88,7 +86,7 @@ export const HelpTooltipTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
);
};
export const HelpTooltipText: FC<HTMLAttributes<HTMLParagraphElement>> = ({
export const HelpPopoverText: FC<HTMLAttributes<HTMLParagraphElement>> = ({
children,
className,
...attrs
@@ -106,12 +104,12 @@ export const HelpTooltipText: FC<HTMLAttributes<HTMLParagraphElement>> = ({
);
};
interface HelpTooltipLink {
interface HelpPopoverLink {
children?: ReactNode;
href: string;
}
export const HelpTooltipLink: FC<HelpTooltipLink> = ({ children, href }) => {
export const HelpPopoverLink: FC<HelpPopoverLink> = ({ children, href }) => {
return (
<a
href={href}
@@ -125,14 +123,14 @@ export const HelpTooltipLink: FC<HelpTooltipLink> = ({ children, href }) => {
);
};
interface HelpTooltipActionProps {
interface HelpPopoverActionProps {
children?: ReactNode;
icon: Icon;
onClick: () => void;
ariaLabel?: string;
}
export const HelpTooltipAction: FC<HelpTooltipActionProps> = ({
export const HelpPopoverAction: FC<HelpPopoverActionProps> = ({
children,
icon: Icon,
onClick,
@@ -151,6 +149,6 @@ export const HelpTooltipAction: FC<HelpTooltipActionProps> = ({
);
};
export const HelpTooltipLinksGroup: FC<PropsWithChildren> = ({ children }) => {
export const HelpPopoverLinksGroup: FC<PropsWithChildren> = ({ children }) => {
return <div className="flex flex-col gap-2 mt-4">{children}</div>;
};
@@ -1,38 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
HelpTooltip,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "./HelpTooltip";
const meta: Meta<typeof HelpTooltip> = {
title: "components/HelpTooltip",
component: HelpTooltip,
args: {
children: (
<>
<HelpTooltipTitle>What is a template?</HelpTooltipTitle>
<HelpTooltipText>
A template is a common configuration for your team&apos;s workspaces.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://github.com/coder/coder/">
Creating a template
</HelpTooltipLink>
<HelpTooltipLink href="https://github.com/coder/coder/">
Updating a template
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof HelpTooltip>;
const Example: Story = {};
export { Example as HelpTooltip };
+1 -12
View File
@@ -43,18 +43,7 @@ export const IconField: FC<IconFieldProps> = ({
endAdornment: hasIcon ? (
<InputAdornment
position="end"
css={{
width: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
maxWidth: "100%",
objectFit: "contain",
},
}}
className="w-6 h-6 flex items-center justify-center [&_img]:max-w-full [&_img]:object-contain"
>
<ExternalImage
alt=""
+1 -1
View File
@@ -24,7 +24,7 @@ export const JetBrainsIcon = (props: SvgIconProps): JSX.Element => (
25.59l85.73-58.84a7.35 7.35 0 0 0 3.17-6.05z"
fill="#fff"
/>
<path d="m60 60h60v60h-60z" css={{ fill: "#000 !important" }} />
<path d="m60 60h60v60h-60z" className="![fill:#000]" />
<g fill="#fff">
<path d="m66.53 108.75h22.5v3.75h-22.5z" />
<path
@@ -18,11 +18,9 @@ type Story = StoryObj<typeof InfoTooltip>;
export const Example: Story = {
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
meta.args.message,
),
expect(screen.getByRole("dialog")).toHaveTextContent(meta.args.message),
);
});
},
@@ -35,9 +33,9 @@ export const Notice = {
},
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
expect(screen.getByRole("dialog")).toHaveTextContent(
Notice.args.message,
),
);
@@ -52,9 +50,9 @@ export const Warning = {
},
play: async ({ step }) => {
await step("activate hover trigger", async () => {
await userEvent.hover(screen.getByRole("button"));
await userEvent.click(screen.getByRole("button"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
expect(screen.getByRole("dialog")).toHaveTextContent(
Warning.args.message,
),
);
+16 -16
View File
@@ -1,12 +1,12 @@
import type { FC, ReactNode } from "react";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipIcon,
HelpTooltipIconTrigger,
HelpTooltipText,
HelpTooltipTitle,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopover,
HelpPopoverContent,
HelpPopoverIcon,
HelpPopoverIconTrigger,
HelpPopoverText,
HelpPopoverTitle,
} from "#/components/HelpPopover/HelpPopover";
import type { ThemeRole } from "#/theme/roles";
import { cn } from "#/utils/cn";
@@ -34,14 +34,14 @@ export const InfoTooltip: FC<InfoTooltipProps> = ({
type = "info",
}) => {
return (
<HelpTooltip>
<HelpTooltipIconTrigger size="small" hoverEffect={false}>
<HelpTooltipIcon className={cn(tooltipColorClasses[type])} />
</HelpTooltipIconTrigger>
<HelpTooltipContent>
{title && <HelpTooltipTitle>{title}</HelpTooltipTitle>}
<HelpTooltipText>{message}</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
<HelpPopover>
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
<HelpPopoverIcon className={cn(tooltipColorClasses[type])} />
</HelpPopoverIconTrigger>
<HelpPopoverContent>
{title && <HelpPopoverTitle>{title}</HelpPopoverTitle>}
<HelpPopoverText>{message}</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
};
+1 -1
View File
@@ -18,7 +18,7 @@ export const Logs: FC<LogsProps> = ({
}) => {
return (
<div css={styles.root} className={`${className} logs-container`}>
<div css={{ minWidth: "fit-content" }}>
<div className="min-w-fit">
{lines.map((line) => (
<LogLine key={line.id} level={line.level}>
{!hideTimestamps && (
+4 -4
View File
@@ -4,6 +4,7 @@ import {
containerWidthMedium,
sidePadding,
} from "#/theme/constants";
import { cn } from "#/utils/cn";
type Size = "regular" | "medium" | "small";
@@ -20,20 +21,19 @@ type MarginsProps = JSX.IntrinsicElements["div"] & {
export const Margins: FC<MarginsProps> = ({
size = "regular",
children,
className,
...divProps
}) => {
const maxWidth = widthBySize[size];
return (
<div
{...divProps}
css={{
marginLeft: "auto",
marginRight: "auto",
style={{
maxWidth: maxWidth,
paddingLeft: sidePadding,
paddingRight: sidePadding,
width: "100%",
}}
className={cn("mx-auto w-full", className)}
>
{children}
</div>
@@ -14,13 +14,8 @@ const meta: Meta<typeof OverflowY> = {
children: numbers.map((num, i) => (
<p
key={num}
css={{
height: "50px",
padding: 0,
margin: 0,
color: "black",
backgroundColor: i % 2 === 0 ? "white" : "gray",
}}
className="h-[50px] p-0 m-0 text-black"
style={{ backgroundColor: i % 2 === 0 ? "white" : "gray" }}
>
Element {num}
</p>
+2 -4
View File
@@ -27,12 +27,10 @@ export const OverflowY: FC<OverflowYProps> = ({
return (
<div
css={{
width: "100%",
className="w-full overflow-y-auto shrink"
style={{
height: computedHeight,
maxHeight: computedMaxHeight,
overflowY: "auto",
flexShrink: 1,
}}
{...attrs}
>
@@ -63,18 +63,7 @@ const _PageHeaderActions: FC<PropsWithChildren> = ({ children }) => {
};
export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
return (
<h1
css={{
fontSize: 18,
fontWeight: 500,
margin: 0,
lineHeight: "24px",
}}
>
{children}
</h1>
);
return <h1 className="text-lg font-medium m-0 leading-6">{children}</h1>;
};
export const PageHeaderSubtitle: FC<PropsWithChildren> = ({ children }) => {
+36 -24
View File
@@ -5,7 +5,11 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "#/utils/cn";
export type PopoverContentProps = PopoverPrimitive.PopoverContentProps;
export type PopoverContentProps = React.ComponentPropsWithRef<
typeof PopoverPrimitive.Content
> & {
disablePortal?: boolean;
};
export type PopoverTriggerProps = PopoverPrimitive.PopoverTriggerProps;
@@ -13,28 +17,36 @@ export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverContent: React.FC<
React.ComponentPropsWithRef<typeof PopoverPrimitive.Content>
> = ({ className, align = "center", sideOffset = 4, ...props }) => {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
collisionPadding={16}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
max-h-[var(--radix-popper-available-height)] overflow-y-auto
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
export const PopoverContent: React.FC<PopoverContentProps> = ({
className,
align = "center",
sideOffset = 4,
disablePortal,
...props
}) => {
const content = (
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
collisionPadding={16}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
max-h-[var(--radix-popper-available-height)] overflow-y-auto
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
className,
)}
{...props}
/>
);
return disablePortal ? (
content
) : (
<PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
);
};
@@ -242,7 +242,7 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
data-testid={`parameter-field-${parameter.name}`}
>
<ParameterLabel parameter={parameter} isPreset={isPreset} />
<div css={{ display: "flex", flexDirection: "column" }}>
<div className="flex flex-col">
<RichParameterField
{...fieldProps}
onChange={onChange}
@@ -271,7 +271,7 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
</FormHelperText>
)}
{autofillSource && autofillDescription[autofillSource] && (
<div css={{ marginTop: 4, fontSize: 12 }}>
<div className="mt-1 text-xs">
🪄 Autofilled {autofillDescription[autofillSource]}
</div>
)}
@@ -345,7 +345,7 @@ const RichParameterField: FC<RichParameterInputProps> = ({
spacing={small ? 1 : 0}
alignItems={small ? "center" : undefined}
direction={small ? "row" : "column"}
css={{ padding: small ? undefined : "4px 0" }}
className={small ? undefined : "py-1"}
>
{small ? (
<Tooltip>
+1 -7
View File
@@ -33,13 +33,7 @@ export const SidebarHeader: FC<SidebarHeaderProps> = ({
return (
<Stack direction="row" spacing={1} className="mb-4">
{avatar}
<div
css={{
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div className="overflow-hidden flex flex-col">
{linkTo ? (
<Link className={cn(titleStyles.normal, "no-underline")} to={linkTo}>
{title}
+1 -3
View File
@@ -7,15 +7,13 @@ import { cn } from "#/utils/cn";
export const TooltipProvider = TooltipPrimitive.Provider;
export type TooltipProps = TooltipPrimitive.TooltipProps;
export const Tooltip = TooltipPrimitive.Root;
export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipArrow = TooltipPrimitive.Arrow;
export type TooltipContentProps = React.ComponentPropsWithRef<
type TooltipContentProps = React.ComponentPropsWithRef<
typeof TooltipPrimitive.Content
> & {
disablePortal?: boolean;
+2 -3
View File
@@ -36,11 +36,10 @@ export const AppStatusStateIcon: FC<AppStatusStateIconProps> = ({
// remove the stroke so it is not overly thick.
return (
<PauseIcon
css={{ strokeWidth: 0 }}
className={cn([
"text-content-secondary",
className,
"text-content-secondary stroke-0",
disabled ? "fill-content-disabled" : "fill-content-secondary",
className,
])}
/>
);
+2 -11
View File
@@ -79,7 +79,7 @@ export const DashboardLayout: FC = () => {
}),
}}
message={
<div css={{ display: "flex", gap: 16 }}>
<div className="flex gap-4">
<InfoIcon
className="size-icon-xs"
css={(theme) => ({
@@ -114,16 +114,7 @@ export const DashboardFullPage: FC<HTMLAttributes<HTMLDivElement>> = ({
...attrs
}) => {
return (
<div
{...attrs}
css={{
flex: 1,
display: "flex",
flexDirection: "column",
flexBasis: 0,
minHeight: "100%",
}}
>
<div {...attrs} className="flex-1 flex flex-col basis-0 min-h-full">
{children}
</div>
);
@@ -24,7 +24,7 @@ import type {
WorkspaceStatus,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { HelpTooltipTitle } from "#/components/HelpTooltip/HelpTooltip";
import { HelpPopoverTitle } from "#/components/HelpPopover/HelpPopover";
import { JetBrainsIcon } from "#/components/Icons/JetBrainsIcon";
import { RocketIcon } from "#/components/Icons/RocketIcon";
import { TerminalIcon } from "#/components/Icons/TerminalIcon";
@@ -137,9 +137,9 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
>
{healthErrors.length > 0 ? (
<>
<HelpTooltipTitle>
<HelpPopoverTitle>
We have detected problems with your Coder deployment.
</HelpTooltipTitle>
</HelpPopoverTitle>
<div className="flex flex-col gap-1">
{healthErrors.map((error) => (
<HealthIssue key={error}>{error}</HealthIssue>
@@ -64,7 +64,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
<Skeleton
width="110px"
height={40}
css={{ borderRadius: 6, transform: "none" }}
className="rounded-[6px] transform-none"
/>
);
}
@@ -6,7 +6,7 @@ import {
} from "#/components/DropdownMenu/DropdownMenu";
import { MockUserOwner } from "#/testHelpers/entities";
import { render, waitForLoaderToBeRemoved } from "#/testHelpers/renderHelpers";
import { Language, UserDropdownContent } from "./UserDropdownContent";
import { UserDropdownContent } from "./UserDropdownContent";
const renderUserDropdownContent = (props: { onSignOut: () => void }) => {
return render(
@@ -28,7 +28,7 @@ describe("UserDropdownContent", () => {
renderUserDropdownContent({ onSignOut: vi.fn() });
await waitForLoaderToBeRemoved();
const link = screen.getByText(Language.accountLabel).closest("a");
const link = screen.getByText("Account").closest("a");
if (!link) {
throw new Error("Anchor tag not found for the account menu item");
}
@@ -40,7 +40,7 @@ describe("UserDropdownContent", () => {
const onSignOut = vi.fn();
renderUserDropdownContent({ onSignOut });
await waitForLoaderToBeRemoved();
screen.getByText(Language.signOutLabel).click();
screen.getByText("Sign Out").click();
expect(onSignOut).toBeCalledTimes(1);
});
});
@@ -21,12 +21,6 @@ import {
import { useClipboard } from "#/hooks/useClipboard";
import { SupportIcon } from "../SupportIcon";
export const Language = {
accountLabel: "Account",
signOutLabel: "Sign Out",
copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`,
};
interface UserDropdownContentProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
@@ -126,7 +120,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
</Tooltip>
)}
<DropdownMenuItem className="text-xs" disabled>
<span>{Language.copyrightText}</span>
<span>&copy; {new Date().getFullYear()} Coder Technologies, Inc.</span>
</DropdownMenuItem>
</>
);
@@ -54,7 +54,7 @@ const DeploymentSettingsLayout: FC = () => {
<section className="px-10 max-w-screen-2xl mx-auto">
<div className="flex flex-row gap-28 py-10">
<DeploymentSidebar />
<div css={{ flexGrow: 1 }}>
<div className="grow">
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
+7 -34
View File
@@ -45,47 +45,20 @@ export const Provisioner: FC<ProvisionerProps> = ({
isWarning && { borderColor: theme.palette.warning.light },
]}
>
<header
css={{
padding: 24,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 24,
}}
>
<div
css={{
display: "flex",
alignItems: "center",
gap: 24,
objectFit: "fill",
}}
>
<div css={{ lineHeight: "160%" }}>
<h4 css={{ fontWeight: 500, margin: 0 }}>{provisioner.name}</h4>
<header className="p-6 flex items-center justify-between gap-6">
<div className="flex items-center gap-6 object-fill">
<div className="leading-[160%]">
<h4 className="font-medium m-0">{provisioner.name}</h4>
<span css={{ color: theme.palette.text.secondary }}>
<code>{provisioner.version}</code>
</span>
</div>
</div>
<div
css={{
marginLeft: "auto",
display: "flex",
flexWrap: "wrap",
gap: 12,
justifyContent: "right",
}}
>
<div className="ml-auto flex flex-wrap gap-3 justify-end">
<Tooltip>
<TooltipTrigger asChild>
<Pill size="lg" icon={iconScope}>
<span
css={{
":first-letter": { textTransform: "uppercase" },
}}
>
<span className="[&::first-letter]:uppercase">
{daemonScope}
</span>
</Pill>
@@ -110,7 +83,7 @@ export const Provisioner: FC<ProvisionerProps> = ({
}}
>
{warnings && warnings.length > 0 ? (
<div css={{ display: "flex", flexDirection: "column" }}>
<div className="flex flex-col">
{warnings.map((warning) => (
<span key={warning.code}>{warning.message}</span>
))}
@@ -35,7 +35,7 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
const { valid, value: boolValue } = parseBool(tagValue);
const kv = (
<>
<span css={{ fontWeight: 600 }}>{tagName}</span> <span>{tagValue}</span>
<span className="font-semibold">{tagName}</span> <span>{tagValue}</span>
</>
);
const content = onDelete ? (
@@ -211,8 +211,8 @@ export const TerraformManagedDirty: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outdatedStatus = canvas.getByText("Outdated");
await userEvent.hover(outdatedStatus);
await screen.findByRole("tooltip");
await userEvent.click(outdatedStatus);
await screen.findByRole("dialog");
},
};
+15 -15
View File
@@ -1,12 +1,12 @@
import type { FC } from "react";
import type { DERPRegion, WorkspaceAgent } from "#/api/typesGenerated";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopover,
HelpPopoverContent,
HelpPopoverText,
HelpPopoverTitle,
HelpPopoverTrigger,
} from "#/components/HelpPopover/HelpPopover";
import { cn } from "#/utils/cn";
import { getLatencyColor } from "#/utils/latency";
@@ -41,8 +41,8 @@ export const AgentLatency: FC<AgentLatencyProps> = ({ agent }) => {
}
return (
<HelpTooltip>
<HelpTooltipTrigger asChild>
<HelpPopover>
<HelpPopoverTrigger asChild>
<span
role="presentation"
aria-label="latency"
@@ -50,13 +50,13 @@ export const AgentLatency: FC<AgentLatencyProps> = ({ agent }) => {
>
{Math.round(latency.latency_ms)}ms
</span>
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>Latency</HelpTooltipTitle>
<HelpTooltipText>
</HelpPopoverTrigger>
<HelpPopoverContent>
<HelpPopoverTitle>Latency</HelpPopoverTitle>
<HelpPopoverText>
This is the latency overhead on non peer to peer connections. The
first row is the preferred relay.
</HelpTooltipText>
</HelpPopoverText>
<div className="flex-col gap-1 mt-4">
{Object.entries(agent.latency)
.sort(([, a], [, b]) => a.latency_ms - b.latency_ms)
@@ -73,7 +73,7 @@ export const AgentLatency: FC<AgentLatencyProps> = ({ agent }) => {
</div>
))}
</div>
</HelpTooltipContent>
</HelpTooltip>
</HelpPopoverContent>
</HelpPopover>
);
};
@@ -27,7 +27,7 @@ export const AgentLogLine: FC<AgentLogLineProps> = ({
}, [line.output]);
return (
<LogLine css={{ paddingLeft: 16 }} level={line.level} style={style}>
<LogLine className="pl-4" level={line.level} style={style}>
{sourceIcon}
<LogLinePrefix
css={styles.number}
@@ -2,14 +2,14 @@ import { RotateCcwIcon } from "lucide-react";
import { type FC, useState } from "react";
import type { WorkspaceAgent } from "#/api/typesGenerated";
import {
HelpTooltip,
HelpTooltipAction,
HelpTooltipContent,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopover,
HelpPopoverAction,
HelpPopoverContent,
HelpPopoverLinksGroup,
HelpPopoverText,
HelpPopoverTitle,
HelpPopoverTrigger,
} from "#/components/HelpPopover/HelpPopover";
import { Stack } from "#/components/Stack/Stack";
import { agentVersionStatus } from "../../utils/workspace";
@@ -39,17 +39,17 @@ export const AgentOutdatedTooltip: FC<AgentOutdatedTooltipProps> = ({
const text = `${opener} This can happen after you update Coder with running workspaces. To fix this, you can stop and start the workspace.`;
return (
<HelpTooltip open={isOpen} onOpenChange={setIsOpen}>
<HelpTooltipTrigger asChild>
<HelpPopover open={isOpen} onOpenChange={setIsOpen}>
<HelpPopoverTrigger asChild>
<span role="status" className="cursor-pointer">
{status === agentVersionStatus.Outdated ? "Outdated" : "Deprecated"}
</span>
</HelpTooltipTrigger>
<HelpTooltipContent>
</HelpPopoverTrigger>
<HelpPopoverContent>
<Stack spacing={1}>
<div>
<HelpTooltipTitle>{title}</HelpTooltipTitle>
<HelpTooltipText>{text}</HelpTooltipText>
<HelpPopoverTitle>{title}</HelpPopoverTitle>
<HelpPopoverText>{text}</HelpPopoverText>
</div>
<Stack spacing={0.5}>
@@ -66,8 +66,8 @@ export const AgentOutdatedTooltip: FC<AgentOutdatedTooltipProps> = ({
<span>{serverVersion}</span>
</Stack>
<HelpTooltipLinksGroup>
<HelpTooltipAction
<HelpPopoverLinksGroup>
<HelpPopoverAction
icon={RotateCcwIcon}
onClick={() => {
onUpdate();
@@ -76,10 +76,10 @@ export const AgentOutdatedTooltip: FC<AgentOutdatedTooltipProps> = ({
ariaLabel="Update workspace"
>
Update workspace
</HelpTooltipAction>
</HelpTooltipLinksGroup>
</HelpPopoverAction>
</HelpPopoverLinksGroup>
</Stack>
</HelpTooltipContent>
</HelpTooltip>
</HelpPopoverContent>
</HelpPopover>
);
};
+2 -2
View File
@@ -68,8 +68,8 @@ const statusBorderClassByLifecycle: Partial<
ready: "border-border-success",
start_timeout: "border-border-warning",
shutdown_timeout: "border-border-warning",
start_error: "border-border-destructive",
shutdown_error: "border-border-destructive",
start_error: "border-border-warning",
shutdown_error: "border-border-warning",
off: "border-border",
};
@@ -0,0 +1,181 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import { MockWorkspaceAgent } from "#/testHelpers/entities";
import {
agentConnectionMessages,
agentScriptMessages,
} from "../workspaces/health";
import { AgentStatus } from "./AgentStatus";
const meta: Meta<typeof AgentStatus> = {
title: "modules/resources/AgentStatus",
component: AgentStatus,
};
export default meta;
type Story = StoryObj<typeof AgentStatus>;
/**
* Shared play helper that hovers the status icon, then asserts the
* tooltip contains the expected title and detail text, plus a
* troubleshoot link when the agent has a troubleshooting URL.
*/
async function expectTooltip(
ariaLabel: string,
title: string,
detail: string,
hasTroubleshootLink: boolean,
) {
const icon = screen.getByRole("status", { name: ariaLabel });
await userEvent.click(icon);
await waitFor(() => {
const tooltip = screen.getByRole("dialog");
expect(tooltip).toHaveTextContent(title);
expect(tooltip).toHaveTextContent(detail);
if (hasTroubleshootLink) {
expect(
within(tooltip).getByRole("link", { name: "Troubleshoot" }),
).toBeInTheDocument();
} else {
expect(
within(tooltip).queryByRole("link", { name: "Troubleshoot" }),
).not.toBeInTheDocument();
}
});
}
export const Ready: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connected",
lifecycle_state: "ready",
},
},
};
export const StartupScriptFailed: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connected",
lifecycle_state: "start_error",
},
},
play: async () => {
await expectTooltip(
"Startup script failed",
agentScriptMessages.start_error.title,
agentScriptMessages.start_error.detail,
true,
);
},
};
export const StartupScriptTimeout: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connected",
lifecycle_state: "start_timeout",
},
},
play: async () => {
await expectTooltip(
"Startup script timeout",
agentScriptMessages.start_timeout.title,
agentScriptMessages.start_timeout.detail,
true,
);
},
};
export const ShutdownScriptFailed: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connected",
lifecycle_state: "shutdown_error",
},
},
play: async () => {
await expectTooltip(
"Shutdown script failed",
agentScriptMessages.shutdown_error.title,
agentScriptMessages.shutdown_error.detail,
true,
);
},
};
export const ShutdownScriptTimeout: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connected",
lifecycle_state: "shutdown_timeout",
},
},
play: async () => {
await expectTooltip(
"Shutdown script timeout",
agentScriptMessages.shutdown_timeout.title,
agentScriptMessages.shutdown_timeout.detail,
true,
);
},
};
export const ConnectionTimeout: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "timeout",
},
},
play: async () => {
await expectTooltip(
"Timeout",
agentConnectionMessages.timeout.title,
agentConnectionMessages.timeout.detail,
true,
);
},
};
export const StartupScriptFailedNoTroubleshootURL: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connected",
lifecycle_state: "start_error",
troubleshooting_url: "",
},
},
play: async () => {
await expectTooltip(
"Startup script failed",
agentScriptMessages.start_error.title,
agentScriptMessages.start_error.detail,
false,
);
},
};
export const Disconnected: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "disconnected",
},
},
};
export const Connecting: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "connecting",
},
},
};
+112 -161
View File
@@ -8,17 +8,22 @@ import type {
} from "#/api/typesGenerated";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopover,
HelpPopoverContent,
HelpPopoverText,
HelpPopoverTitle,
HelpPopoverTrigger,
} from "#/components/HelpPopover/HelpPopover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import {
agentConnectionMessages,
agentScriptMessages,
} from "../workspaces/health";
// If we think in the agent status and lifecycle into a single enum/state I'd
// say we would have: connecting, timeout, disconnected, connected:created,
@@ -26,6 +31,56 @@ import {
// connected:ready, connected:shutting_down, connected:shutdown_timeout,
// connected:shutdown_error, connected:off.
interface AgentWarningTooltipProps {
ariaLabel: string;
title: string;
detail: string;
troubleshootingURL?: string;
variant?: "warning" | "error";
}
/**
* Shared tooltip for agent warning/error states. Renders an alert
* icon with a help tooltip showing the title, detail, and an
* optional troubleshooting link.
*/
const AgentWarningTooltip: FC<AgentWarningTooltipProps> = ({
ariaLabel,
title,
detail,
troubleshootingURL,
variant = "warning",
}) => {
return (
<HelpPopover>
<HelpPopoverTrigger asChild role="status" aria-label={ariaLabel}>
<TriangleAlertIcon
className={cn(
"relative size-3.5",
variant === "warning"
? "text-content-warning"
: "text-content-destructive",
)}
/>
</HelpPopoverTrigger>
<HelpPopoverContent>
<HelpPopoverTitle>{title}</HelpPopoverTitle>
<HelpPopoverText>
{detail}
{troubleshootingURL && (
<>
{" "}
<Link target="_blank" rel="noreferrer" href={troubleshootingURL}>
Troubleshoot
</Link>
</>
)}
</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
};
const ReadyLifecycle: FC = () => {
return (
<div
@@ -66,54 +121,24 @@ interface DevcontainerStatusProps {
agent?: WorkspaceAgent;
}
const StartTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => {
return (
<HelpTooltip>
<HelpTooltipTrigger asChild role="status" aria-label="Agent timeout">
<TriangleAlertIcon css={styles.timeoutWarning} />
</HelpTooltipTrigger>
const StartTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => (
<AgentWarningTooltip
ariaLabel="Startup script timeout"
title={agentScriptMessages.start_timeout.title}
detail={agentScriptMessages.start_timeout.detail}
troubleshootingURL={agent.troubleshooting_url}
/>
);
<HelpTooltipContent>
<HelpTooltipTitle>Agent is taking too long to start</HelpTooltipTitle>
<HelpTooltipText>
We noticed this agent is taking longer than expected to start.{" "}
<Link
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
>
Troubleshoot
</Link>
.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const StartErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => {
return (
<HelpTooltip>
<HelpTooltipTrigger asChild role="status" aria-label="Start error">
<TriangleAlertIcon css={styles.errorWarning} />
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>Error starting the agent</HelpTooltipTitle>
<HelpTooltipText>
Something went wrong during the agent startup.{" "}
<Link
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
>
Troubleshoot
</Link>
.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const StartErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => (
<AgentWarningTooltip
ariaLabel="Startup script failed"
title={agentScriptMessages.start_error.title}
detail={agentScriptMessages.start_error.detail}
troubleshootingURL={agent.troubleshooting_url}
variant="warning"
/>
);
const ShuttingDownLifecycle: FC = () => {
return (
@@ -130,53 +155,24 @@ const ShuttingDownLifecycle: FC = () => {
);
};
const ShutdownTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => {
return (
<HelpTooltip>
<HelpTooltipTrigger asChild role="status" aria-label="Stop timeout">
<TriangleAlertIcon css={styles.timeoutWarning} />
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>Agent is taking too long to stop</HelpTooltipTitle>
<HelpTooltipText>
We noticed this agent is taking longer than expected to stop.{" "}
<Link
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
>
Troubleshoot
</Link>
.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const ShutdownTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => (
<AgentWarningTooltip
ariaLabel="Shutdown script timeout"
title={agentScriptMessages.shutdown_timeout.title}
detail={agentScriptMessages.shutdown_timeout.detail}
troubleshootingURL={agent.troubleshooting_url}
/>
);
const ShutdownErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => {
return (
<HelpTooltip>
<HelpTooltipTrigger asChild role="status" aria-label="Stop error">
<TriangleAlertIcon css={styles.errorWarning} />
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>Error stopping the agent</HelpTooltipTitle>
<HelpTooltipText>
Something went wrong while trying to stop the agent.{" "}
<Link
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
>
Troubleshoot
</Link>
.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const ShutdownErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => (
<AgentWarningTooltip
ariaLabel="Shutdown script failed"
title={agentScriptMessages.shutdown_error.title}
detail={agentScriptMessages.shutdown_error.detail}
troubleshootingURL={agent.troubleshooting_url}
variant="warning"
/>
);
const OffLifecycle: FC = () => {
return (
@@ -259,29 +255,14 @@ const ConnectingStatus: FC = () => {
);
};
const TimeoutStatus: FC<AgentStatusProps> = ({ agent }) => {
return (
<HelpTooltip>
<HelpTooltipTrigger asChild role="status" aria-label="Timeout">
<TriangleAlertIcon css={styles.timeoutWarning} />
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>Agent is taking too long to connect</HelpTooltipTitle>
<HelpTooltipText>
We noticed this agent is taking longer than expected to connect.{" "}
<Link
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
>
Troubleshoot
</Link>
.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const TimeoutStatus: FC<AgentStatusProps> = ({ agent }) => (
<AgentWarningTooltip
ariaLabel="Timeout"
title={agentConnectionMessages.timeout.title}
detail={agentConnectionMessages.timeout.detail}
troubleshootingURL={agent.troubleshooting_url}
/>
);
export const AgentStatus: FC<AgentStatusProps> = ({ agent }) => {
return (
@@ -324,31 +305,15 @@ const SubAgentStatus: FC<SubAgentStatusProps> = ({ agent }) => {
);
};
const DevcontainerStartError: FC<AgentStatusProps> = ({ agent }) => {
return (
<HelpTooltip>
<HelpTooltipTrigger asChild role="status" aria-label="Start error">
<TriangleAlertIcon css={styles.errorWarning} />
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>
Error starting the devcontainer agent
</HelpTooltipTitle>
<HelpTooltipText>
Something went wrong during the devcontainer agent startup.{" "}
<Link
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
>
Troubleshoot
</Link>
.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const DevcontainerStartError: FC<AgentStatusProps> = ({ agent }) => (
<AgentWarningTooltip
ariaLabel="Start error"
title="Error starting the devcontainer agent"
detail="Something went wrong during the devcontainer agent startup."
troubleshootingURL={agent.troubleshooting_url}
variant="error"
/>
);
export const DevcontainerStatus: FC<DevcontainerStatusProps> = ({
devcontainer,
@@ -398,18 +363,4 @@ const styles = {
backgroundColor: theme.palette.info.light,
animation: "$pulse 1.5s 0.5s ease-in-out forwards infinite",
}),
timeoutWarning: (theme) => ({
color: theme.palette.warning.light,
width: 14,
height: 14,
position: "relative",
}),
errorWarning: (theme) => ({
color: theme.palette.error.main,
width: 14,
height: 14,
position: "relative",
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -37,10 +37,10 @@ import {
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import {
HelpTooltipLink,
HelpTooltipText,
HelpTooltipTitle,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopoverLink,
HelpPopoverText,
HelpPopoverTitle,
} from "#/components/HelpPopover/HelpPopover";
import {
Popover,
PopoverContent,
@@ -251,42 +251,26 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
return (
<>
<div
css={{
maxHeight: 320,
overflowY: "auto",
}}
>
<Stack
direction="column"
css={{
padding: 20,
}}
>
<div className="max-h-80 overflow-y-auto">
<Stack direction="column" className="p-5">
<Stack
direction="row"
justifyContent="space-between"
alignItems="start"
>
<HelpTooltipTitle>Listening Ports</HelpTooltipTitle>
<HelpTooltipLink
<HelpPopoverTitle>Listening Ports</HelpPopoverTitle>
<HelpPopoverLink
href={docs("/admin/networking/port-forwarding#dashboard")}
>
Learn more
</HelpTooltipLink>
</HelpPopoverLink>
</Stack>
<Stack direction="column" gap={1}>
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
<HelpPopoverText css={{ color: theme.palette.text.secondary }}>
The listening ports are exclusively accessible to you. Selecting
HTTP/S will change the protocol for all listening ports.
</HelpTooltipText>
<Stack
direction="row"
gap={2}
css={{
paddingBottom: 8,
}}
>
</HelpPopoverText>
<Stack direction="row" gap={2} className="pb-2">
<FormControl size="small" css={styles.protocolFormControl}>
<Select
css={styles.listeningPortProtocol}
@@ -346,9 +330,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
</Stack>
</Stack>
{filteredListeningPorts.length === 0 && (
<HelpTooltipText css={styles.noPortText}>
<HelpPopoverText css={styles.noPortText}>
No open ports were detected.
</HelpTooltipText>
</HelpPopoverText>
)}
{filteredListeningPorts.map((port) => {
const url = portForwardURL(
@@ -431,12 +415,12 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
borderTop: `1px solid ${theme.palette.divider}`,
}}
>
<HelpTooltipTitle>Shared Ports</HelpTooltipTitle>
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
<HelpPopoverTitle>Shared Ports</HelpPopoverTitle>
<HelpPopoverText css={{ color: theme.palette.text.secondary }}>
{canSharePorts
? "Ports can be shared with organization members, other Coder users, or with the public."
: "This workspace template does not allow sharing ports. Contact a template administrator to enable port sharing."}
</HelpTooltipText>
</HelpPopoverText>
{canSharePorts && (
<div>
{filteredSharedPorts?.map((share) => {
+2 -5
View File
@@ -114,12 +114,9 @@ export const ResourceCard: FC<ResourceCardProps> = ({ resource, agentRow }) => {
</Stack>
<div
css={{
flexGrow: 2,
display: "grid",
className="grow-[2] grid gap-x-10 gap-y-6"
style={{
gridTemplateColumns: `repeat(${gridWidth}, minmax(0, 1fr))`,
gap: 40,
rowGap: 24,
}}
>
{resource.daily_cost > 0 && (
@@ -5,10 +5,10 @@ import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import { CodeExample } from "#/components/CodeExample/CodeExample";
import {
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopoverLink,
HelpPopoverLinksGroup,
HelpPopoverText,
} from "#/components/HelpPopover/HelpPopover";
import {
Popover,
PopoverContent,
@@ -44,9 +44,9 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
align="end"
className="py-4 px-6 w-80 text-content-secondary mt-[2px] bg-surface-secondary"
>
<HelpTooltipText>
<HelpPopoverText>
Run the following commands to connect with SSH:
</HelpTooltipText>
</HelpPopoverText>
<ol style={{ margin: 0, padding: 0 }}>
<Stack spacing={0.5} className="mt-3">
@@ -61,25 +61,25 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
</Stack>
</ol>
<HelpTooltipLinksGroup>
<HelpTooltipLink href={docs("/install")}>
<HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/install")}>
Install Coder CLI
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/workspace-access/vscode")}>
</HelpPopoverLink>
<HelpPopoverLink href={docs("/user-guides/workspace-access/vscode")}>
Connect via VS Code Remote SSH
</HelpTooltipLink>
<HelpTooltipLink
</HelpPopoverLink>
<HelpPopoverLink
href={docs("/user-guides/workspace-access/jetbrains")}
>
Connect via JetBrains IDEs
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/desktop")}>
</HelpPopoverLink>
<HelpPopoverLink href={docs("/user-guides/desktop")}>
Connect via Coder Desktop
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
</HelpPopoverLink>
<HelpPopoverLink href={docs("/user-guides/workspace-access#ssh")}>
SSH configuration
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpPopoverLink>
</HelpPopoverLinksGroup>
</PopoverContent>
</Popover>
);
@@ -92,9 +92,9 @@ interface SSHStepProps {
const SSHStep: FC<SSHStepProps> = ({ helpText, codeExample }) => (
<li style={{ listStylePosition: "inside" }}>
<HelpTooltipText style={{ display: "inline" }}>
<HelpPopoverText style={{ display: "inline" }}>
<strong className="text-xs">{helpText}</strong>
</HelpTooltipText>
</HelpPopoverText>
<CodeExample secret={false} code={codeExample} />
</li>
);
@@ -8,11 +8,6 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
const Language = {
showLabel: "Show value",
hideLabel: "Hide value",
};
interface SensitiveValueProps {
value: string;
}
@@ -20,7 +15,7 @@ interface SensitiveValueProps {
export const SensitiveValue: FC<SensitiveValueProps> = ({ value }) => {
const [shouldDisplay, setShouldDisplay] = useState(false);
const displayValue = shouldDisplay ? value : "••••••••";
const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel;
const buttonLabel = shouldDisplay ? "Hide value" : "Show value";
const icon = shouldDisplay ? (
<EyeOffIcon className="size-icon-xs" />
) : (
@@ -5,14 +5,14 @@ import type {
WorkspaceAgentDevcontainer,
} from "#/api/typesGenerated";
import {
HelpTooltip,
HelpTooltipAction,
HelpTooltipContent,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "#/components/HelpTooltip/HelpTooltip";
import { TooltipTrigger } from "#/components/Tooltip/Tooltip";
HelpPopover,
HelpPopoverAction,
HelpPopoverContent,
HelpPopoverLinksGroup,
HelpPopoverText,
HelpPopoverTitle,
HelpPopoverTrigger,
} from "#/components/HelpPopover/HelpPopover";
type SubAgentOutdatedTooltipProps = {
devcontainer: WorkspaceAgentDevcontainer;
@@ -33,34 +33,34 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
}
return (
<HelpTooltip>
<TooltipTrigger className="px-0 py-1 bg-transparent text-inherit border-none opacity-50 hover:opacity-100">
<HelpPopover>
<HelpPopoverTrigger className="px-0 py-1 bg-transparent text-inherit border-none opacity-50 hover:opacity-100">
<span role="status" className="cursor-pointer">
Outdated
</span>
</TooltipTrigger>
<HelpTooltipContent>
</HelpPopoverTrigger>
<HelpPopoverContent>
<div className="flex flex-col gap-2">
<div>
<HelpTooltipTitle>Dev Container Outdated</HelpTooltipTitle>
<HelpTooltipText>
<HelpPopoverTitle>Dev Container Outdated</HelpPopoverTitle>
<HelpPopoverText>
This Dev Container is outdated. This can happen if you modify your
devcontainer.json file after the Dev Container has been created.
To fix this, you can rebuild the Dev Container.
</HelpTooltipText>
</HelpPopoverText>
</div>
<HelpTooltipLinksGroup>
<HelpTooltipAction
<HelpPopoverLinksGroup>
<HelpPopoverAction
icon={RotateCcwIcon}
onClick={onUpdate}
ariaLabel="Rebuild Dev Container"
>
Rebuild Dev Container
</HelpTooltipAction>
</HelpTooltipLinksGroup>
</HelpPopoverAction>
</HelpPopoverLinksGroup>
</div>
</HelpTooltipContent>
</HelpTooltip>
</HelpPopoverContent>
</HelpPopover>
);
};
@@ -78,21 +78,21 @@ export const VSCodeDesktopButton: FC<VSCodeDesktopButtonProps> = (props) => {
}}
>
<MenuItem
css={{ fontSize: 14 }}
className="text-sm"
onClick={() => {
selectVariant("vscode");
}}
>
<VSCodeIcon css={{ width: 12, height: 12 }} />
<VSCodeIcon className="w-3 h-3" />
{DisplayAppNameMap.vscode}
</MenuItem>
<MenuItem
css={{ fontSize: 14 }}
className="text-sm"
onClick={() => {
selectVariant("vscode-insiders");
}}
>
<VSCodeInsidersIcon css={{ width: 12, height: 12 }} />
<VSCodeInsidersIcon className="w-3 h-3" />
{DisplayAppNameMap.vscode_insiders}
</MenuItem>
</Menu>
@@ -82,21 +82,21 @@ export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
}}
>
<MenuItem
css={{ fontSize: 14 }}
className="text-sm"
onClick={() => {
selectVariant("vscode");
}}
>
<VSCodeIcon css={{ width: 12, height: 12 }} />
<VSCodeIcon className="w-3 h-3" />
{DisplayAppNameMap.vscode}
</MenuItem>
<MenuItem
css={{ fontSize: 14 }}
className="text-sm"
onClick={() => {
selectVariant("vscode-insiders");
}}
>
<VSCodeInsidersIcon css={{ width: 12, height: 12 }} />
<VSCodeInsidersIcon className="w-3 h-3" />
{DisplayAppNameMap.vscode_insiders}
</MenuItem>
</Menu>
@@ -23,7 +23,7 @@ export const TemplateExampleCard: FC<TemplateExampleCardProps> = ({
<div css={styles.icon}>
<ExternalImage
src={example.icon}
css={{ width: "100%", height: "100%", objectFit: "contain" }}
className="w-full h-full object-contain"
/>
</div>
@@ -39,15 +39,13 @@ export const TemplateExampleCard: FC<TemplateExampleCardProps> = ({
</div>
<div>
<h4 css={{ fontSize: 14, fontWeight: 600, margin: 0, marginBottom: 4 }}>
{example.name}
</h4>
<h4 className="text-sm font-semibold m-0 mb-1">{example.name}</h4>
<span css={styles.description}>
{example.description}{" "}
<Link
component={RouterLink}
to={`/starter-templates/${example.id}`}
css={{ display: "inline-block", fontSize: 13, marginTop: 4 }}
className="inline-block text-[13px] mt-1"
>
Read more
</Link>
@@ -30,7 +30,7 @@ export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
color: theme.roles[statusType].fill.solid,
}}
/>
<div css={{ overflow: "hidden" }}>
<div className="overflow-hidden">
<div
css={{
color: theme.palette.text.primary,
@@ -42,9 +42,8 @@ export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
gap: 4,
}}
>
<span css={{ textTransform: "capitalize" }}>{build.transition}</span>{" "}
by{" "}
<span css={{ fontWeight: 500 }}>
<span className="capitalize">{build.transition}</span> by{" "}
<span className="font-medium">
{getDisplayWorkspaceBuildInitiatedBy(build)}
</span>
{!systemBuildReasons.includes(build.reason) &&
@@ -83,12 +82,7 @@ export const WorkspaceBuildDataSkeleton = () => {
<Skeleton variant="circular" width={16} height={16} />
<div>
<Skeleton variant="text" width={94} height={16} />
<Skeleton
variant="text"
width={60}
height={14}
css={{ marginTop: 2 }}
/>
<Skeleton variant="text" width={60} height={14} className="mt-0.5" />
</div>
</div>
);
@@ -13,10 +13,6 @@ import { DEFAULT_LOG_LINE_SIDE_PADDING, Logs } from "#/components/Logs/Logs";
import { BODY_FONT_FAMILY } from "#/theme/constants";
import { cn } from "#/utils/cn";
const Language = {
seconds: "seconds",
};
type Stage = ProvisionerJobLog["stage"];
type LogsGroupedByStage = Record<Stage, ProvisionerJobLog[]>;
type GroupLogsByStageFn = (logs: ProvisionerJobLog[]) => LogsGroupedByStage;
@@ -98,9 +94,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
>
<div>{stage}</div>
{shouldDisplayDuration && (
<div css={styles.duration}>
{duration} {Language.seconds}
</div>
<div css={styles.duration}>{duration} seconds</div>
)}
</div>
{!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />}
@@ -142,7 +142,7 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
}
}}
description={
<Stack css={{ paddingBottom: 16 }}>
<Stack className="pb-4">
<p>
Downloading logs will create a zip file containing all logs from all
jobs in this workspace. This may take a while.
@@ -61,7 +61,7 @@ export const UpdateBuildParametersDialog: FC<
Workspace parameters
</DialogTitle>
<DialogContent css={styles.content}>
<DialogContentText css={{ margin: 0 }}>
<DialogContentText className="m-0">
This template has new parameters that must be configured to complete
the update
</DialogContentText>
@@ -74,7 +74,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
<p className="name">{workspace.name}</p>
<p className="label">workspace</p>
</div>
<div css={{ textAlign: "right" }}>
<div className="text-right">
<p className="info">{dayjs(workspace.created_at).fromNow()}</p>
<p className="label">created</p>
</div>
@@ -90,7 +90,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
<TextField
fullWidth
autoFocus
css={{ marginTop: 32 }}
className="mt-8"
name="confirmation"
autoComplete="off"
id={`${hookId}-confirm`}
@@ -113,9 +113,9 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
/>
{hasTask && (
<div css={styles.warnContainer}>
<div css={{ flexDirection: "column" }}>
<div className="flex-col">
<p className="info">This workspace is related to a task</p>
<span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
<span className="text-xs mt-1 block">
Deleting this workspace will also delete{" "}
<Link
href={`/tasks/${workspace.owner_name}/${workspace.task_id}`}
@@ -129,7 +129,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
)}
{canOrphan && (
<div css={styles.warnContainer}>
<div css={{ flexDirection: "column" }}>
<div className="flex-col">
<Checkbox
id="orphan_resources"
size="small"
@@ -143,9 +143,9 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
data-testid="orphan-checkbox"
/>
</div>
<div css={{ flexDirection: "column" }}>
<div className="flex-col">
<p className="info">Orphan Resources</p>
<span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
<span className="text-xs mt-1 block">
As a Template Admin, you may skip resource cleanup to delete
a failed workspace. Resources such as volumes and virtual
machines will not be destroyed.&nbsp;
@@ -45,6 +45,7 @@ type WorkspaceMoreActionsProps = {
disabled: boolean;
onStop?: () => void;
isStopping?: boolean;
onActionSuccess?: () => Promise<void> | void;
};
export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
@@ -52,6 +53,7 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
disabled,
onStop,
isStopping,
onActionSuccess,
}) => {
const queryClient = useQueryClient();
@@ -97,8 +99,13 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
// Delete
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const deleteWorkspaceOptions = deleteWorkspace(workspace, queryClient);
const deleteWorkspaceMutation = useMutation({
...deleteWorkspace(workspace, queryClient),
...deleteWorkspaceOptions,
onSuccess: async (build) => {
await deleteWorkspaceOptions.onSuccess?.(build);
await onActionSuccess?.();
},
onError: (error: unknown) => {
handleError(error);
},
@@ -37,9 +37,9 @@ const Example: Story = {
const body = within(canvasElement.ownerDocument.body);
await step("activate hover trigger", async () => {
await userEvent.hover(body.getByRole("button"));
await userEvent.click(body.getByRole("button"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
expect(screen.getByRole("dialog")).toHaveTextContent(
MockTemplateVersion.message,
),
);
@@ -9,15 +9,15 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { templateVersion } from "#/api/queries/templates";
import type { Workspace } from "#/api/typesGenerated";
import {
HelpTooltip,
HelpTooltipAction,
HelpTooltipContent,
HelpTooltipIconTrigger,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "#/components/HelpTooltip/HelpTooltip";
HelpPopover,
HelpPopoverAction,
HelpPopoverContent,
HelpPopoverIconTrigger,
HelpPopoverLinksGroup,
HelpPopoverText,
HelpPopoverTitle,
HelpPopoverTrigger,
} from "#/components/HelpPopover/HelpPopover";
import { linkToTemplate, useLinks } from "#/modules/navigation";
import {
useWorkspaceUpdate,
@@ -36,22 +36,22 @@ export const WorkspaceOutdatedTooltip: FC<WorkspaceOutdatedTooltipProps> = ({
const [isOpen, setIsOpen] = useState(false);
return (
<HelpTooltip open={isOpen} onOpenChange={setIsOpen}>
<HelpPopover open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<HelpTooltipTrigger asChild>
<HelpPopoverTrigger asChild>
<span className="flex items-center gap-1.5 cursor-help">
<InfoIcon css={styles.icon} size={14} />
<span>{children}</span>
</span>
</HelpTooltipTrigger>
</HelpPopoverTrigger>
) : (
<HelpTooltipIconTrigger size="small" hoverEffect={false}>
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
<InfoIcon css={styles.icon} />
<span className="sr-only">Outdated info</span>
</HelpTooltipIconTrigger>
</HelpPopoverIconTrigger>
)}
<WorkspaceOutdatedTooltipContent isOpen={isOpen} workspace={workspace} />
</HelpTooltip>
</HelpPopover>
);
};
@@ -86,14 +86,14 @@ const WorkspaceOutdatedTooltipContent: FC<TooltipContentProps> = ({
return (
<>
<HelpTooltipContent disablePortal={false}>
<HelpTooltipTitle>Outdated</HelpTooltipTitle>
<HelpTooltipText>
<HelpPopoverContent disablePortal={false}>
<HelpPopoverTitle>Outdated</HelpPopoverTitle>
<HelpPopoverText>
This workspace version is outdated and a newer version is available.
</HelpTooltipText>
</HelpPopoverText>
<div css={styles.container}>
<div css={{ lineHeight: "1.6" }}>
<div className="leading-[1.6]">
<div css={styles.bold}>New version</div>
<div>
{activeVersion ? (
@@ -110,7 +110,7 @@ const WorkspaceOutdatedTooltipContent: FC<TooltipContentProps> = ({
</div>
</div>
<div css={{ lineHeight: "1.6" }}>
<div className="leading-[1.6]">
<div css={styles.bold}>Message</div>
<div>
{activeVersion ? (
@@ -122,15 +122,15 @@ const WorkspaceOutdatedTooltipContent: FC<TooltipContentProps> = ({
</div>
</div>
<HelpTooltipLinksGroup>
<HelpTooltipAction
<HelpPopoverLinksGroup>
<HelpPopoverAction
icon={RotateCcwIcon}
onClick={updateWorkspace.update}
>
Update
</HelpTooltipAction>
</HelpTooltipLinksGroup>
</HelpTooltipContent>
</HelpPopoverAction>
</HelpPopoverLinksGroup>
</HelpPopoverContent>
<WorkspaceUpdateDialogs {...updateWorkspace.dialogs} />
</>
);
@@ -177,13 +177,7 @@ export const StagesChart: FC<StagesChartProps> = ({
}}
>
{t.error && (
<CircleAlertIcon
className="size-icon-sm"
css={{
color: "#F87171",
marginRight: 4,
}}
/>
<CircleAlertIcon className="size-icon-sm text-[#F87171] mr-1" />
)}
<Blocks count={t.visibleResources} />
</ClickableBar>
@@ -285,7 +285,7 @@ export const InvalidTimeRange: Story = {
export const MultipleAgents: Story = {
decorators: [
(Story) => (
<div css={{ "--collapse-body-height": "600px" }}>
<div style={{ "--collapse-body-height": "600px" } as React.CSSProperties}>
<Story />
</div>
),
+54 -12
View File
@@ -1,5 +1,51 @@
import type { Workspace, WorkspaceAgentStatus } from "#/api/typesGenerated";
/**
* Canonical messages for startup and shutdown script issues.
* Used by the per-agent-row tooltips in AgentStatus; the
* start-related entries are also shared with the workspace-level
* health classification in getAgentHealthIssue.
*/
export const agentScriptMessages = {
start_error: {
title: "Startup script failed",
detail:
"A startup script exited with an error. Check the agent logs for details.",
},
start_timeout: {
title: "Startup script is taking longer than expected",
detail:
"A startup script has exceeded the expected time. Check the agent logs for details.",
},
shutdown_error: {
title: "Shutdown script failed",
detail:
"A shutdown script exited with an error. Check the agent logs for details.",
},
shutdown_timeout: {
title: "Shutdown script is taking longer than expected",
detail:
"A shutdown script has exceeded the expected time. Check the agent logs for details.",
},
} as const;
/**
* Canonical messages for agent connection issues (the agent
* process connecting to the Coder control plane).
*/
export const agentConnectionMessages = {
timeout: {
title: "Agent is taking longer than expected to connect",
detail:
"Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.",
},
disconnected: {
title: "Workspace agent has disconnected",
detail:
"Check the log output for errors. If agents do not reconnect, try restarting the workspace.",
},
} as const;
interface AgentHealthIssue {
title: string;
detail: string;
@@ -53,9 +99,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
return {
title: plural
? `${failingAgentCount} workspace agents have disconnected`
: "Workspace agent has disconnected",
detail:
"Check the log output for errors. If agents do not reconnect, try restarting the workspace.",
: agentConnectionMessages.disconnected.title,
detail: agentConnectionMessages.disconnected.detail,
severity: "warning",
prominent: true,
};
@@ -65,9 +110,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
return {
title: plural
? `${failingAgentCount} agents are taking longer than expected to connect`
: "Agent is taking longer than expected to connect",
detail:
"Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.",
: agentConnectionMessages.timeout.title,
detail: agentConnectionMessages.timeout.detail,
severity: "warning",
prominent: false,
};
@@ -88,9 +132,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
return {
title: plural
? `Startup scripts failed on ${failingAgentCount} agents`
: "Startup script failed",
detail:
"A startup script exited with an error. Check the agent logs for details.",
: agentScriptMessages.start_error.title,
detail: agentScriptMessages.start_error.detail,
severity: "warning",
prominent: true,
};
@@ -105,9 +148,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
return {
title: plural
? `Startup scripts are taking longer than expected on ${failingAgentCount} agents`
: "Startup script is taking longer than expected",
detail:
"A startup script has exceeded the expected time. Check the agent logs for details.",
: agentScriptMessages.start_timeout.title,
detail: agentScriptMessages.start_timeout.detail,
severity: "warning",
prominent: false,
};
@@ -0,0 +1,32 @@
import type { FC } from "react";
import {
HelpPopover,
HelpPopoverContent,
HelpPopoverIconTrigger,
HelpPopoverLink,
HelpPopoverLinksGroup,
HelpPopoverText,
HelpPopoverTitle,
} from "#/components/HelpPopover/HelpPopover";
import { docs } from "#/utils/docs";
export const AIBridgeHelpPopover: FC = () => {
return (
<HelpPopover>
<HelpPopoverIconTrigger />
<HelpPopoverContent>
<HelpPopoverTitle>What is AI Bridge?</HelpPopoverTitle>
<HelpPopoverText>
AI Bridge is a smart gateway for AI that provides centralized
management, auditing, and attribution for LLM usage.
</HelpPopoverText>
<HelpPopoverLinksGroup>
<HelpPopoverLink href={docs("/ai-coder/ai-bridge")}>
Read the docs
</HelpPopoverLink>
</HelpPopoverLinksGroup>
</HelpPopoverContent>
</HelpPopover>
);
};

Some files were not shown because too many files have changed in this diff Show More