Compare commits

...

234 Commits

Author SHA1 Message Date
Stephen Kirby f7cf3e7fc2 feat(site): add Arch Linux icon to bundled icon set 2026-04-02 15:04:49 +00:00
Yevhenii Shcherbina cc143c8990 docs: add byok docs for aibridge (#23922)
Adds documentation for BYOK (Bring Your Own Keys) for AIBridge.

Covers claude-code and codex.
2026-04-02 09:59:04 -04:00
Jeremy Ruppel 3def04a3ee fix(site): use overflow-hidden for prompt text overflow (#23977)
Also had Claude write a couple of stories for the SessionTimeline 🤖 but
they look good
2026-04-02 13:58:18 +00:00
Cian Johnston d4a9c63e91 feat: auto-assign agents-access role to new users when experiment enabled (#23968)
When the `agents` experiment is enabled, new users are automatically
granted the `agents-access` role at creation time so they can use Coder
Agents without manual admin intervention.

- Auto-assigns in `CreateUser()` — covers admin API, OAuth, and OIDC
creation paths
- Skips auto-assign for OIDC users when enterprise site role sync is
enabled (sync overwrites roles on every login; those admins should use
`--oidc-user-role-default` instead)
- CLI `create-admin-user` bypasses `CreateUser()` but creates `owner`
users who already have all permissions

> 🤖 Written by a Coder Agent. Will be reviewed by a human.
2026-04-02 14:46:47 +01:00
Danielle Maywood 00217fefa5 fix: invalidate PR diff cache on git refresh button click (#23974) 2026-04-02 13:39:04 +00:00
Danny Kopping fce05d0428 feat: add backport PR script (#23973)
_Disclaimer: created using Claude Opus 4.6._

```
# Examples:
#   ./scripts/backport-pr.sh 2.30 23969
#   ./scripts/backport-pr.sh --dry-run 2.30 23969
```

Here's one I created: https://github.com/coder/coder/pull/23972

Signed-off-by: Danny Kopping <danny@coder.com>
2026-04-02 15:19:06 +02:00
Cian Johnston 2ebc076b9e fix: make 'chat has no workspace agent' error actually helpful (#23971)
- Change `errChatHasNoWorkspaceAgent` message from cryptic `"chat has no
workspace agent"` to actionable `"workspace has no running agent: the
workspace may be stopped. Use the start_workspace tool to start it, or
create_workspace to create a new one"`
- Update test assertions to match the new message substring

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-02 14:18:26 +01:00
Mathias Fredriksson e71dc6dd4d feat: add TypeScript and React reference docs for deep-review Modernization Reviewer (#23502)
Add language reference docs that the Modernization Reviewer reads
before reviewing TS/React code, matching the existing Go reference
(.claude/docs/GO.md).

- references/typescript.md: Modern TypeScript 5.0-6.0 RC patterns,
  replacements, and new capabilities
- references/react.md: Modern React 18-19.2 + Compiler 1.0 patterns,
  replacements, and new capabilities

SKILL.md updated to reference these docs in the Tier 2 file filters
and spawn prompt instructions.

Refs #23500
2026-04-02 12:58:48 +00:00
Jeremy Ruppel ca3ae3643d fix(site): session threads feedback (#23945) 2026-04-02 08:51:31 -04:00
Danny Kopping ed5c06f039 chore: link to audit docs and add prompt attribution tooltip on AI Bridge sessions page (#23969)
*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6*

## Summary

Two changes on the AI Bridge sessions page
(`/aibridge/sessions/<session>`):

1. **Updated header subtitle and link** — replaced the generic
"Centralized auditing for LLM usage across your organization. More about
AI Governance" with auditing-specific copy and a link to the [AI Bridge
audit docs](https://coder.com/docs/ai-coder/ai-bridge/audit).

2. **Added prompt attribution tooltip** — each user prompt now shows an
info icon with a tooltip explaining that prompt origin cannot be
reliably determined (human vs. agent), linking to the [attribution
docs](https://coder.com/docs/ai-coder/ai-bridge/audit#human-vs-agent-attribution).

## Changes

| File | What changed |
|------|-------------|
| `AIBridgeSessionsLayout.tsx` | Updated subtitle text and link target |
| `SessionTimeline.tsx` | Added `InfoIcon` + `Tooltip` next to the
"Prompt" label in `ThreadItem` |

<img width="954" height="318" alt="image"
src="https://github.com/user-attachments/assets/db3ca443-cb0f-426a-8457-4625c82fd6ba"
/>

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2026-04-02 08:43:30 -04:00
Danielle Maywood 1221622bf0 fix(site/src/api/queries): optimistically truncate cache on chat message edit (#23864) 2026-04-02 12:41:33 +01:00
Mathias Fredriksson aa5ec0bfcc feat(site/src/pages/AgentsPage): add copy button to ProposePlanTool (#23940)
Reuse the CopyButton component to let users copy plan content from
the propose_plan tool output. Follows the same pattern used by the
assistant message copy button.
2026-04-02 14:30:36 +03:00
Cian Johnston 16add93908 fix(coderd/x/chatd): stabilize subagent pubsub completion test (#23944)
- stabilize `TestAwaitSubagentCompletion/CompletesViaPubsub` by waiting
for durable completion state before sending the synthetic pubsub wake
- add coverage for successful subagent completion with an empty report

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-02 12:29:47 +01:00
Susana Ferreira fe13fd065c chore: downgrade log level for unauthenticated HEAD requests (#23923)
Some clients (e.g. Claude) send a HEAD request without credentials as a
connectivity check before making actual API calls. This was logging at
`Warn` level, creating noise. Downgrade to Info for unauthenticated HEAD
requests and add the HTTP method to the logger for better observability.

Related to internal slack thread:
https://codercom.slack.com/archives/C0AEHQGLW22/p1775045200997309
2026-04-02 11:30:22 +01:00
Susana Ferreira fb788530b3 feat: add provider_name column to aibridge interceptions (#23960)
## Description

Adds `provider_name` to aibridge interceptions to store the provider
instance name alongside the provider type. This allows distinguishing
between multiple instances of the same provider type (e.g. `copilot` vs
`copilot-business`).

## Changes

* Add `provider_name` column to `aibridge_interceptions` table with
backfill from `provider`.
* Add `provider_name` field to the proto `RecordInterceptionRequest`
message.
* Add `ProviderName` to the `codersdk.AIBridgeInterception` API
response.

_Disclaimer: initially produced by Claude Opus 4.6, modified and
reviewed by @ssncferreira ._
2026-04-02 10:58:13 +01:00
Ethan f4dc8f6b11 test: use non-monitoring RPC role in apptest setup (#23953)
Closes https://github.com/coder/internal/issues/1432
Closes https://github.com/coder/internal/issues/1399

The test setup in `createWorkspaceWithApps` opens a short-lived RPC
connection to fetch the agent manifest before starting the real agent.
This connection used `ConnectRPC()` which sends no `role` parameter, so
the server treated it as a real agent connection and enabled connection
monitoring. When the helper closed, its monitor asynchronously wrote
`disconnectedAt` to the DB — racing with the real agent's monitor and
transiently marking the agent as disconnected.

The fix uses `ConnectRPCWithRole(ctx, "apptest-manifest")` so the helper
doesn't trigger connection monitoring. The server already has this
role-based distinction for non-agent clients like
`coder-logstream-kube`; the test helper just wasn't using it.

Both issues share this codepath: `setupProxyTest` →
`createWorkspaceWithApps` → the `ConnectRPC` call at `setup.go:518`.
Both test configurations have a non-empty `PrimaryAppHost`, so both
enter the affected block.

This is not masking a product issue — the "disconnected" state was
caused by two competing monitors writing to the same agent DB row, a
scenario that only exists in this test setup. No assertions were
weakened; the proxy still checks real agent connectivity on every
request.
2026-04-02 20:01:54 +11:00
Mathias Fredriksson bbeff0d4b5 fix(site/src/pages/AgentsPage): hide copy button during active turn (#23962)
The copy button on the last assistant message was showing even while the
turn was still in progress (agent streaming or running tool calls). The
content is not final at that point, so the button should be suppressed
until the turn completes.

The `lastAssistantPerTurnIds` computation unconditionally included the
trailing assistant message. Now it checks a new `isTurnActive` prop
derived from `isActiveChatStatus(chatStatus) || hasStreamState` and
skips the trailing ID when the turn is active. Completed turns (those
followed by a user message) are unaffected.
2026-04-02 08:58:00 +00:00
dependabot[bot] 78fa8094cc chore: bump github.com/gohugoio/hugo from 0.158.0 to 0.159.2 (#23957)
Bumps [github.com/gohugoio/hugo](https://github.com/gohugoio/hugo) from
0.158.0 to 0.159.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/gohugoio/hugo/releases">github.com/gohugoio/hugo's
releases</a>.</em></p>
<blockquote>
<h2>v0.159.2</h2>
<p>Note that the security fix below is not a potential threat if you
either:</p>
<ul>
<li>Trust your Markdown content files.</li>
<li>Have custom <a href="https://gohugo.io/render-hooks/">render hook
template</a> for links and images.</li>
</ul>
<p>EDIT IN: This release also adds release archives for
non-extended-withdeploy builds.</p>
<h2>What's Changed</h2>
<ul>
<li>Fix potential content XSS by escaping dangerous URLs in Markdown
links and images 479fe6c6 <a
href="https://github.com/bep"><code>@​bep</code></a></li>
<li>resources/page: Fix shared reader in
Source.ValueAsOpenReadSeekCloser df520e31 <a
href="https://github.com/jmooring"><code>@​jmooring</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14684">#14684</a></li>
</ul>
<h2>v0.159.1</h2>
<p>The regression fixed in this release isn't new, but it's so subtle
that we thought we'd release this sooner rather than later. For some
time now, the minifier we use have stripped namespaced attributes in
SVGs, which broke dynamic constructs using e.g. <a
href="https://alpinejs.dev/directives/bind">AlpineJS' x-bind:</a>
namespace (library used by Hugo's <a
href="https://gohugo.io/">documentation site</a>).</p>
<p>To fix this, the upstream library has hadded a
<code>keepNamespaces</code> slice option. It was not possible to find a
default that would make all happy, so we opted for an option that at
least would make AlpineJS sites work out of the box:</p>
<pre lang="toml"><code> [minify.tdewolff.svg]
      keepNamespaces = ['', 'x-bind']
</code></pre>
<h2>What's Changed</h2>
<ul>
<li>minifiers: Keep x-bind and blank namespace in SVG minification
42289d76 <a href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14669">#14669</a></li>
</ul>
<h2>v0.159.0</h2>
<p>This release greatly improves and simplifies management of
Node.js/npm dependencies in a multi-module setup. See <a
href="https://gohugo.io/hugo-modules/nodejs-dependencies/">this page</a>
for more information.</p>
<h2>Note</h2>
<ul>
<li>Replace deprecated site.Data with hugo.Data in tests a8fca598 <a
href="https://github.com/bep"><code>@​bep</code></a></li>
<li>Replace deprecated excludeFiles and includeFiles with files in tests
182b1045 <a href="https://github.com/bep"><code>@​bep</code></a></li>
<li>Replace deprecated :filename with :contentbasename in the permalinks
test eb11c3d0 <a
href="https://github.com/bep"><code>@​bep</code></a></li>
</ul>
<h2>Bug fixes</h2>
<ul>
<li>tpl/tplimpl: Fix Vimeo shortcode test eaf4c751 <a
href="https://github.com/jmooring"><code>@​jmooring</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14649">#14649</a></li>
</ul>
<h2>Improvements</h2>
<ul>
<li>create: Return error instead of panic when page not found 807cae1d
<a href="https://github.com/mango766"><code>@​mango766</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14112">#14112</a></li>
<li>commands: Preserve non-content files in convert output c4fb61d9 <a
href="https://github.com/xndvaz"><code>@​xndvaz</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/4621">#4621</a></li>
<li>npm: Use workspaces to simplify <code>hugo mod npm pack</code>
d88a29e0 <a href="https://github.com/bep"><code>@​bep</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gohugoio/hugo/commit/5f4646acaad89e1166aac118e118b0d28013f460"><code>5f4646a</code></a>
releaser: Bump versions for release of 0.159.2</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/479fe6c654937a850b65e74551dc4e857d52898f"><code>479fe6c</code></a>
Fix potential content XSS by escaping dangerous URLs in links and
images</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/81a5cdca0788ca39574a17d444c9db29d0b19e27"><code>81a5cdc</code></a>
releaser: Add standard withdeploy release assets</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/df520e315087210e069050a873fb5e208659af91"><code>df520e3</code></a>
resources/page: Fix shared reader in
Source.ValueAsOpenReadSeekCloser</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/b55d452e46e81369a65978459a0683efa484c11b"><code>b55d452</code></a>
testing: Simplify line ending handling in tests</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/ea7eac65589a7c6e138a2dfba12615335b29bef3"><code>ea7eac6</code></a>
readme: Update Go version to 1.25.0</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/458ebdd448492d82113b0149886f2b5f8e7b91a4"><code>458ebdd</code></a>
releaser: Prepare repository for 0.160.0-DEV</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/86c7d3afacab79dc53325602d77ef884b7570268"><code>86c7d3a</code></a>
releaser: Bump versions for release of 0.159.1</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/42289d76f9ccac052c22b84d7024e64cfb0a683b"><code>42289d7</code></a>
minifiers: Keep x-bind and blank namespace in SVG minification</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/0c013c2326be1bbebba9a9f89d27072074fc6cc4"><code>0c013c2</code></a>
Adjust depreceated syntax in tests</li>
<li>Additional commits viewable in <a
href="https://github.com/gohugoio/hugo/compare/v0.158.0...v0.159.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/gohugoio/hugo&package-manager=go_modules&previous-version=0.158.0&new-version=0.159.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 08:44:24 +00:00
dependabot[bot] a85e00eed0 chore: bump google.golang.org/grpc from 1.79.3 to 1.80.0 (#23956)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.79.3 to 1.80.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.80.0</h2>
<h1>Behavior Changes</h1>
<ul>
<li>balancer: log a warning if a balancer is registered with uppercase
letters, as balancer names should be lowercase. In a future release,
balancer names will be treated as case-insensitive; see <a
href="https://redirect.github.com/grpc/grpc-go/issues/5288">#5288</a>
for details. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8837">#8837</a>)</li>
<li>xds: update resource error handling and re-resolution logic (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8907">#8907</a>)
<ul>
<li>Re-resolve all <code>LOGICAL_DNS</code> clusters simultaneously when
re-resolution is requested.</li>
<li>Fail all in-flight RPCs immediately upon receipt of listener or
route resource errors, instead of allowing them to complete.</li>
</ul>
</li>
</ul>
<h1>Bug Fixes</h1>
<ul>
<li>xds: support the LB policy configured in <code>LOGICAL_DNS</code>
cluster resources instead of defaulting to <code>pick_first</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8733">#8733</a>)</li>
<li>credentials/tls: perform per-RPC authority validation against the
leaf certificate instead of the entire peer certificate chain. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8831">#8831</a>)</li>
<li>xds: enabling A76 ring hash endpoint keys no longer causes EDS
resources with invalid proxy metadata to be NACKed when HTTP CONNECT
(gRFC A86) is disabled. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8875">#8875</a>)</li>
<li>xds: validate that the sum of endpoint weights in a locality does
not exceed the maximum <code>uint32</code> value. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8899">#8899</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/RAVEYUS"><code>@​RAVEYUS</code></a></li>
</ul>
</li>
<li>xds: fix incorrect proto field access in the weighted round robin
(WRR) configuration where <code>blackout_period</code> was used instead
of <code>weight_expiration_period</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8915">#8915</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/gregbarasch"><code>@​gregbarasch</code></a></li>
</ul>
</li>
<li>xds/rbac: handle addresses with ports in IP matchers. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8990">#8990</a>)</li>
</ul>
<h1>New Features</h1>
<ul>
<li>ringhash: enable gRFC A76 (endpoint hash keys and request hash
headers) by default. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8922">#8922</a>)</li>
</ul>
<h1>Performance Improvements</h1>
<ul>
<li>credentials/alts: pool write buffers to reduce memory allocations
and usage. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8919">#8919</a>)</li>
<li>grpc: enable the use of pooled write buffers for buffering HTTP/2
frame writes by default. This reduces memory usage when connections are
idle. Use the <a
href="https://pkg.go.dev/google.golang.org/grpc#WithSharedWriteBuffer">WithSharedWriteBuffer</a>
dial option or the <a
href="https://pkg.go.dev/google.golang.org/grpc#SharedWriteBuffer">SharedWriteBuffer</a>
server option to disable this feature. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8957">#8957</a>)</li>
<li>xds/priority: stop caching child LB policies removed from the
configuration. This will help reduce memory and cpu usage when
localities are constantly switching between priorities. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8997">#8997</a>)</li>
<li>mem: add a faster tiered buffer pool; use the experimental <a
href="https://pkg.go.dev/google.golang.org/grpc/mem@master#NewBinaryTieredBufferPool">mem.NewBinaryTieredBufferPool</a>
function to create such pools. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8775">#8775</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/grpc/grpc-go/commit/397e45edaa68f8763773bbaaf539cf7894169cd2"><code>397e45e</code></a>
Change version to 1.80.0 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8948">#8948</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/64ebf0a600005838970e6ba1eb0a9e46e528ed73"><code>64ebf0a</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/8997">#8997</a> to
v1.80.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9027">#9027</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/e45ed241865981b6973cdd0dd69571456d570282"><code>e45ed24</code></a>
xds/rbac: add additional handling for addresses with ports (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8990">#8990</a>)
(<a
href="https://redirect.github.com/grpc/grpc-go/issues/9022">#9022</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/c78d26e03e129f5cb357b757037fcded2333b74e"><code>c78d26e</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/8957">#8957</a> to
v1.80.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9007">#9007</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/bd7cd3c1abbd27fb751275a58886444d52103482"><code>bd7cd3c</code></a>
grpc: enforce strict path checking for incoming requests on the server
(<a
href="https://redirect.github.com/grpc/grpc-go/issues/8987">#8987</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/b6597b3d328c1ed6b003f9a23b942af7148352ca"><code>b6597b3</code></a>
xds/clusterimpl: use xdsConfig for updates and remove redundant fields
from L...</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/1d4fa8a7b772553e82137b059ad4a8f632a1c522"><code>1d4fa8a</code></a>
xds: change cdsbalancer to use update from dependency manager (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8907">#8907</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/8f47d364511c8eb0517b47e1a39f13a1370c6a10"><code>8f47d36</code></a>
attributes: Replace internal map with linked list (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8933">#8933</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/22e1ee8085952b4bdadf2928c187d665f6daff99"><code>22e1ee8</code></a>
xds: add panic recovery in xdsclient resource unmarshalling. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8895">#8895</a>)</li>
<li><a
href="https://github.com/grpc/grpc-go/commit/7136e99ee323c26984174eb3cec85c201fef9946"><code>7136e99</code></a>
credentials/alts: Pool write buffers (<a
href="https://redirect.github.com/grpc/grpc-go/issues/8919">#8919</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/grpc/grpc-go/compare/v1.79.3...v1.80.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/grpc&package-manager=go_modules&previous-version=1.79.3&new-version=1.80.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 08:28:01 +00:00
dependabot[bot] da50a34414 ci: bump the github-actions group with 2 updates (#23958)
Bumps the github-actions group with 2 updates:
[azure/setup-helm](https://github.com/azure/setup-helm) and
[chromaui/action](https://github.com/chromaui/action).

Updates `azure/setup-helm` from 4.3.1 to 5.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/azure/setup-helm/releases">azure/setup-helm's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<h3>Changed</h3>
<ul>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/259">#259</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/259">Update
Node.js runtime from node20 to node24</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/263">#263</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/263">Bump
undici</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/257">#257</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/257">Bump
undici and <code>@​actions/http-client</code></a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/256">#256</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/256">Bump
minimatch</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/248">#248</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/248">Bump the
actions group with 2 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/247">#247</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/247">Bump the
actions group with 3 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/246">#246</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/246">Bump
<code>@​types/node</code> from 25.0.2 to 25.0.3 in the actions
group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/245">#245</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/245">Bump the
actions group with 3 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/243">#243</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/243">Bump the
actions group with 2 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/240">#240</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/240">Bump
prettier from 3.6.2 to 3.7.3 in the actions group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/229">#229</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/229">Bump the
actions group across 1 directory with 3 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/231">#231</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/231">Bump
js-yaml from 3.14.1 to 3.14.2</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/234">#234</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/234">Bump
glob from 10.4.5 to 10.5.0</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/225">#225</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/225">Fix
build error</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/222">#222</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/222">Bump
<code>@​types/node</code> from 24.7.2 to 24.8.1 in the actions
group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/220">#220</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/220">Bump the
actions group across 1 directory with 4 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/216">#216</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/216">Bump the
actions group across 1 directory with 4 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/213">#213</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/213">Bump the
actions group with 2 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/211">#211</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/211">Bump
undici</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/212">#212</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/212">Bump
jest from 30.0.5 to 30.1.2 in the actions group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/210">#210</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/210">Bump
<code>@​types/node</code> from 24.2.1 to 24.3.0 in the actions
group</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/Azure/setup-helm/blob/main/CHANGELOG.md">azure/setup-helm's
changelog</a>.</em></p>
<blockquote>
<h1>Change Log</h1>
<h2>[5.0.0] - 2026-03-23</h2>
<h3>Changed</h3>
<ul>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/259">#259</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/259">Update
Node.js runtime from node20 to node24</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/263">#263</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/263">Bump
undici</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/257">#257</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/257">Bump
undici and <code>@​actions/http-client</code></a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/256">#256</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/256">Bump
minimatch</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/248">#248</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/248">Bump the
actions group with 2 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/247">#247</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/247">Bump the
actions group with 3 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/246">#246</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/246">Bump
<code>@​types/node</code> from 25.0.2 to 25.0.3 in the actions
group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/245">#245</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/245">Bump the
actions group with 3 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/243">#243</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/243">Bump the
actions group with 2 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/240">#240</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/240">Bump
prettier from 3.6.2 to 3.7.3 in the actions group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/229">#229</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/229">Bump the
actions group across 1 directory with 3 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/231">#231</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/231">Bump
js-yaml from 3.14.1 to 3.14.2</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/234">#234</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/234">Bump
glob from 10.4.5 to 10.5.0</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/225">#225</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/225">Fix
build error</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/222">#222</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/222">Bump
<code>@​types/node</code> from 24.7.2 to 24.8.1 in the actions
group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/220">#220</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/220">Bump the
actions group across 1 directory with 4 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/216">#216</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/216">Bump the
actions group across 1 directory with 4 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/213">#213</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/213">Bump the
actions group with 2 updates</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/211">#211</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/211">Bump
undici</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/212">#212</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/212">Bump
jest from 30.0.5 to 30.1.2 in the actions group</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/210">#210</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/210">Bump
<code>@​types/node</code> from 24.2.1 to 24.3.0 in the actions
group</a></li>
</ul>
<h2>[4.3.1] - 2025-08-12</h2>
<h3>Changed</h3>
<ul>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/167">#167</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/167">Pinning
Action Dependencies for Security and Reliability</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/181">#181</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/181">Fix
types, and update node version.</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/191">#191</a>
<a
href="https://redirect.github.com/Azure/setup-helm/pull/191">chore(tests):
Mock arch to make tests pass on arm host</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/192">#192</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/192">chore:
remove unnecessary prebuild script</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/203">#203</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/203">Update
helm version retrieval to use JSON output for latest version</a></li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/207">#207</a>
<a
href="https://redirect.github.com/Azure/setup-helm/pull/207">ci(workflows):
update helm version to v3.18.4 and add matrix for tests</a></li>
</ul>
<h3>Added</h3>
<ul>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/197">#197</a>
<a href="https://redirect.github.com/Azure/setup-helm/pull/197">Add
pre-commit hook</a></li>
</ul>
<h2>[4.3.0] - 2025-02-15</h2>
<ul>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/152">#152</a>
feat: log when restoring from cache</li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/157">#157</a>
Dependencies Update</li>
<li><a
href="https://redirect.github.com/azure/setup-helm/issues/137">#137</a>
Add dependabot</li>
</ul>
<h2>[4.2.0] - 2024-04-15</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/Azure/setup-helm/commit/dda3372f752e03dde6b3237bc9431cdc2f7a02a2"><code>dda3372</code></a>
build</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/3894c84c36e1df49b57e75c2c2c70dc6cf18c2bb"><code>3894c84</code></a>
chore(release): v5.0.0 (<a
href="https://redirect.github.com/azure/setup-helm/issues/265">#265</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/ca66f3880d072ee3ace311d70cd267faa2ce7c73"><code>ca66f38</code></a>
Update Node.js runtime from node20 to node24 (<a
href="https://redirect.github.com/azure/setup-helm/issues/259">#259</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/316ed5ab42b09c96cccd063ecc054a7c0f1dc207"><code>316ed5a</code></a>
Bump undici (<a
href="https://redirect.github.com/azure/setup-helm/issues/263">#263</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/bc9bc0ca28599851490e89b2629b3e181c4d1209"><code>bc9bc0c</code></a>
Bump undici and <code>@​actions/http-client</code> (<a
href="https://redirect.github.com/azure/setup-helm/issues/257">#257</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/16e3094bcbb6e98d8a39a05e244dd020c0a68a74"><code>16e3094</code></a>
Bump minimatch (<a
href="https://redirect.github.com/azure/setup-helm/issues/256">#256</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/6e427537330e9c850849a06695599f9d298c46cc"><code>6e42753</code></a>
Bump actions/stale in /.github/workflows in the actions group (<a
href="https://redirect.github.com/azure/setup-helm/issues/255">#255</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/9651d9df522c20a9f1fef182b439689e91e24103"><code>9651d9d</code></a>
Bump actions/checkout in /.github/workflows in the actions group (<a
href="https://redirect.github.com/azure/setup-helm/issues/251">#251</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/658bff9449986fae55b983dd9dbb242943fb9513"><code>658bff9</code></a>
Bump the actions group with 2 updates (<a
href="https://redirect.github.com/azure/setup-helm/issues/248">#248</a>)</li>
<li><a
href="https://github.com/Azure/setup-helm/commit/331c81409ca70b46d873b7b6655b7ed3e9d7c2b6"><code>331c814</code></a>
Bump the actions group with 3 updates (<a
href="https://redirect.github.com/azure/setup-helm/issues/247">#247</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/azure/setup-helm/compare/1a275c3b69536ee54be43f2070a358922e12c8d4...dda3372f752e03dde6b3237bc9431cdc2f7a02a2">compare
view</a></li>
</ul>
</details>
<br />

Updates `chromaui/action` from 13.3.5 to 16.0.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/chromaui/action/blob/main/CHANGELOG.md">chromaui/action's
changelog</a>.</em></p>
<blockquote>
<h1>v16.0.0 (Mon Mar 23 2026)</h1>
<h4>💥 Breaking Change</h4>
<ul>
<li>Drop support for Node 18 and update GitHub Action to Node 24 <a
href="https://redirect.github.com/chromaui/chromatic-cli/pull/1251">#1251</a>
(<a href="https://github.com/codykaup"><code>@​codykaup</code></a>)</li>
</ul>
<h4>Authors: 1</h4>
<ul>
<li>Cody Kaup (<a
href="https://github.com/codykaup"><code>@​codykaup</code></a>)</li>
</ul>
<hr />
<h1>v15.3.1 (Mon Mar 23 2026)</h1>
<h4>🐛 Bug Fix</h4>
<ul>
<li>Properly timeout process tree in shell commands <a
href="https://redirect.github.com/chromaui/chromatic-cli/pull/1254">#1254</a>
(<a href="https://github.com/codykaup"><code>@​codykaup</code></a>)</li>
</ul>
<h4>Authors: 1</h4>
<ul>
<li>Cody Kaup (<a
href="https://github.com/codykaup"><code>@​codykaup</code></a>)</li>
</ul>
<hr />
<h1>v15.3.0 (Mon Mar 16 2026)</h1>
<h4>🚀 Enhancement</h4>
<ul>
<li>Integrate manifest generation script <a
href="https://redirect.github.com/chromaui/chromatic-cli/pull/1244">#1244</a>
(<a href="https://github.com/codykaup"><code>@​codykaup</code></a>)</li>
</ul>
<h4>Authors: 1</h4>
<ul>
<li>Cody Kaup (<a
href="https://github.com/codykaup"><code>@​codykaup</code></a>)</li>
</ul>
<hr />
<h1>v15.2.0 (Mon Feb 23 2026)</h1>
<h4>🚀 Enhancement</h4>
<ul>
<li>❇️ Add input parameter chromaticSha. <a
href="https://redirect.github.com/chromaui/chromatic-cli/pull/1241">#1241</a>
(<a href="https://github.com/jwir3"><code>@​jwir3</code></a>)</li>
</ul>
<h4>Authors: 1</h4>
<ul>
<li>Scott Johnson (<a
href="https://github.com/jwir3"><code>@​jwir3</code></a>)</li>
</ul>
<hr />
<h1>v15.1.1 (Tue Feb 17 2026)</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/chromaui/action/commit/f191a0224b10e1a38b2091cefb7b7a2337009116"><code>f191a02</code></a>
v16.0.0</li>
<li><a
href="https://github.com/chromaui/action/commit/eea1606238fd97a70b5af723d103953d1f40967b"><code>eea1606</code></a>
v15.3.1</li>
<li><a
href="https://github.com/chromaui/action/commit/0794e6939fe40ce46a88963f818092afc427da5b"><code>0794e69</code></a>
v15.3.0</li>
<li><a
href="https://github.com/chromaui/action/commit/5ec258af08deb3e8c36653bd618cb7fe52090031"><code>5ec258a</code></a>
v15.2.0</li>
<li><a
href="https://github.com/chromaui/action/commit/93712e37669f856c4828136f9cb535dbfa60b50c"><code>93712e3</code></a>
v15.1.1</li>
<li><a
href="https://github.com/chromaui/action/commit/a8ce9c58f59be5cc7090cadfc8f130fb08fcf0c3"><code>a8ce9c5</code></a>
v15.1.0</li>
<li><a
href="https://github.com/chromaui/action/commit/f1f9e3277eb1eaa8cba4c6bcebc9809291ee29ea"><code>f1f9e32</code></a>
v15.0.0</li>
<li><a
href="https://github.com/chromaui/action/commit/9f1ad414f2e282aeff674364a9098b328382103f"><code>9f1ad41</code></a>
v14.0.0</li>
<li>See full diff in <a
href="https://github.com/chromaui/action/compare/07791f8243f4cb2698bf4d00426baf4b2d1cb7e0...f191a0224b10e1a38b2091cefb7b7a2337009116">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 08:26:36 +00:00
Cian Johnston cd784c755a fix(agent): exorcise data race haunting contextConfigAPI on reconnect (#23946)
Fixes: coder/internal#1441

- Move `contextConfigAPI` init from `handleManifest` to `init()`,
matching all other API fields
- Change `agentcontextconfig.NewAPI` to accept `func() string` closure
(lazy directory evaluation)
- `Config()` and HTTP handler now compute on demand via
`a.manifest.Load().Directory`
- Widen `TestAgent_Reconnect` to loop 5 reconnections with a non-empty
manifest directory
- Add `TestContextConfigAPI_InitOnce` internal test verifying lazy eval
across manifest changes
- Add `TestNewAPI_LazyDirectory` unit test for the lazy contract

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-02 09:00:13 +01:00
dependabot[bot] eb4860aac3 chore: bump the coder-modules group across 2 directories with 2 updates (#23955)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 07:54:01 +00:00
dependabot[bot] 07fbe8ca7d chore: bump ubuntu from ce4a593 to 5e5b128 in /dogfood/coder (#23954)
Bumps ubuntu from `ce4a593` to `5e5b128`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ubuntu&package-manager=docker&previous-version=jammy&new-version=jammy)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 07:53:42 +00:00
Jake Howell ba0a64d483 chore: move to using radix-ui over @radix-ui/react-* (#23911)
This pull-request moves using to using the plain `radix-ui` package over
`@radix-ui/react-*` packages. Put simply, now we're not going to run
into issues with inconsistent radix dependencies. This will have no
effect to how the code is built, but will give us a single place to
import from.
2026-04-02 18:49:33 +11:00
Ethan 7757cd8e08 refactor(coderd/x/chatd): insert chats directly as pending on creation (#23888)
Previously, `CreateChat` inserted the `chats` row with the DB default
status (`waiting`), then updated it to `pending` in the same transaction
via `setChatPendingWithStore`. This wasted two extra queries per chat
creation (`GetChatByID` + `UpdateChatStatus`) and rewrote the same row
immediately after inserting it.

Now `CreateChat` passes the status directly to `InsertChat`, so the row
is written once in its final create-time state. The
`setChatPendingWithStore` helper is removed entirely. `InsertChat` now
requires an explicit `status` parameter at all callsites instead of
relying on a DB column default.

## Motivation

On an experimental branch we're trialing firing all chatd notifications
from plpgsql triggers. The old two-step insert made that awkward: in an
`AFTER INSERT` trigger, `NEW` only contained the insert-time row
(`waiting`), not the final committed state (`pending`). To emit the
correct event payload the trigger had to be deferred and re-read the row
from `chats` at commit time.

With this change, `NEW` already contains the correct row to publish — no
deferred trigger, no extra `SELECT`, simpler and cheaper trigger logic.

That said, this seems like a worthwhile change regardless of the trigger
experiment: writing the final row state once removes unnecessary DB work
on every chat creation and makes the create path easier to reason about.
2026-04-02 14:13:51 +11:00
Ethan fc1e0beb3b fix(coderd/x/chatd): use structured output for chat title generation (#23909)
Chat title generation used free-form text completion, which let models
respond conversationally instead of producing a title. Review chats
started with GitHub URLs were especially affected — models would say "I
don't have the ability to browse external links" and that string became
the persisted title.

Replace the raw-text `generateShortText` path with structured output via
`object.Generate[generatedTitle]`. Both auto-title and manual retitle
now go through the same typed contract: the model must return a JSON
object with a `title` field, validated and normalized before
persistence. Invalid outputs (empty, too long) are rejected and retried
through the existing candidate-model fallback loop.
2026-04-02 14:13:27 +11:00
Ben Potter 3a4a0b7270 fix: rename "Add member" to "Add" on template permissions page (#23943)
The "Add member" button on the template permissions page is used to add
both **users and groups**, so the label is misleading when adding a
group.

<img width="1238" height="672" alt="image"
src="https://github.com/user-attachments/assets/dbdfc79e-9e2e-4f26-9258-418f2038511e"
/>
2026-04-01 14:46:18 -06:00
Spike Curtis 11c1afb5e9 chore: add support for tailnet updates to Tunneler FSM (#23875)
<!--

If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting.

-->

relates to GRU-18  
  
Adds support for tailnet updates to Tunneler FSM.
2026-04-01 16:02:30 -04:00
Garrett Delfosse be2e641162 feat: add release candidate (RC) support to release tooling (#23600)
This adds full RC release support to the release scripts and GitHub
Actions workflow. Previously, the tooling only supported stable and
mainline releases with strict vMAJOR.MINOR.PATCH semver tags.

Changes:
- scripts/releaser/version.go: Add Pre field to version struct for
prerelease suffixes (e.g. "rc.0"), update regex, parsing, String(),
comparison methods, and add IsRC()/rcNumber() helpers.
- scripts/releaser/release.go: Detect RC branches (release/X.Y-rc.N),
suggest RC version numbers, auto-set "rc" channel (skipping
stable/mainline prompt), add RC advisory to release notes, skip docs
update for RC releases.
- .github/workflows/release.yaml: Add "rc" channel option, fix branch
derivation for RC tags (v2.32.0-rc.0 -> release/2.32-rc.0 instead of
broken release/2.32.0-rc), skip homebrew/winget/package publishing for
RC releases.
- scripts/release/publish.sh: Add --rc flag, pass --prerelease to gh
release create for RC releases.
- scripts/releaser/version_test.go: Add comprehensive unit tests for
version parsing, string formatting, IsRC, rcNumber, GreaterThan, and
Equal with RC versions.

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->
2026-04-01 16:00:49 -04:00
Spike Curtis 83e2699914 chore: add support for app updates to Tunneler FSM (#23874)
<!--

If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting.

-->

relates to GRU-18  
  
Adds support for network application (e.g. SSH) updates to Tunneler.
2026-04-01 15:52:03 -04:00
Cian Johnston 515ba209fd ci: fix weekly-docs check failing on pnpm cache save (#23937) 2026-04-01 20:04:46 +01:00
Garrett Delfosse d15bfc2cb0 fix(install.sh): filter pre-release tags from mainline version resolution (#23939)
The `echo_latest_mainline_version()` function fetches all GitHub
releases and sorts by version number to find the latest mainline
release. It did not filter out pre-release tags (e.g. `v2.32.0-rc.0`),
so publishing an RC release caused `coder.com/install.sh` to resolve the
RC as the latest mainline version instead of the actual mainline
release.

Adds a `grep` filter for strict semver (`MAJOR.MINOR.PATCH`) before
sorting, so tags with pre-release suffixes like `-rc.0` are excluded
from version resolution.
2026-04-01 18:23:57 +00:00
Asher 308053b0e4 fix: stop workspace before starting with new parameters (#23541)
This is required to prevent the agent from becoming unhealthy.

Since we are stopping the workspace now, also add a confirmation dialog.

Also add stories to test the new behavior and make a tweak to the
permissions query in support of that.
2026-04-01 10:00:03 -08:00
Jeremy Ruppel 7c29355e84 fix: specify allowed hosts for storybook dev server (#23938)
We recently upgraded storybook and vite in #23485 which bumped our
`storybook` version from 10.2.10 to 10.3.3. In 10.2.16,
storybookjs/storybook#34045 was merged that changes the list of default
allowed hosts to an empty array. This means if you have custom DNS set
up (like through the Coder desktop app) your `.coder` domain will no
longer be able to reach storybook and you'll get an `Invalid host`
response. This is a breaking change, but storybook didn't treat it as
such.

This PR adds the `core.allowedHosts` config to our storybook dev server.
I'm not sure this has the same effect for build so I left the other
`viteFinal` `server.allowedHosts` config, but it may be defunct
2026-04-01 17:44:26 +00:00
Kyle Carberry 7c048d8eb4 fix(site): fix "Thinking..." indicator disappearing prematurely (#23933)
The "Thinking..." indicator flickered or failed to appear when the user
sent a message.

## Problem

The server sends `status:pending` before `status:running` when
processing a new message. `selectIsAwaitingFirstStreamChunk` only
accepted `"running"`, so during the pending window the indicator was
hidden. When the optimistic `setChatStatus("running")` from `handleSend`
was overridden by the WS `status:pending` event, the indicator would
flash and disappear.

Secondarily, `StreamingOutput` hid the indicator as soon as
`streamState` became non-null, even when no text/reasoning blocks
existed yet (e.g. only tool-call parts or whitespace-only deltas had
arrived).

## Fix

1. **`chatStore.ts`** — `selectIsAwaitingFirstStreamChunk` now also
accepts `chatStatus === "pending"` when the latest durable message is a
user message (fresh send). Tool-call cycles (where latest =
assistant/tool) remain unaffected.

2. **`StreamingOutput.tsx`** — During streaming, the component keeps
showing "Thinking..." until a text or reasoning block appears, bridging
the visual gap between the startup placeholder and the first visible
content.

3. **`streamState.ts`** — Changed the early-return guard for
text/reasoning parts from `!part.text` to `!part.text?.trim()` so
whitespace-only deltas don't create a non-null `StreamState` with empty
blocks.

<details><summary>Decision log</summary>

- Including `"pending"` in `isAwaitingFirstStreamChunk` was previously
rejected because it caused the 15-second "startup taking longer" warning
during tool-call cycles. The `latestMessage?.role === "user"` guard now
prevents that — during tool cycles the latest durable message is
assistant/tool, not user.
- The `StreamingOutput` streaming-thinking check uses a synthetic
`"starting"` status for `ChatStatusCallout` rather than adding a new
phase to `LiveStatusModel`, keeping the status model clean.
- The whitespace trim fix in `streamState.ts` is defense-in-depth — the
`StreamingOutput` fix handles the rendering gap, but preventing
empty-block `StreamState` creation is the correct behavior at the
source.

</details>
2026-04-01 13:03:59 -04:00
Jake Howell e81275a91c feat: cleanup <Tabs /> component (#23839)
This refactors `<Tabs />` into two clearer patterns: link tabs for route
navigation and Radix tabs for stateful tab panels. That gives us proper
accessibility semantics where we need them without overloading simple
navigation tabs.

As part of that split, this updates several consumers, adds coverage for
both variants, and cleans up some nearby styling.

- introduce Radix-backed tabs primitives for tabbed content
- move router-based tabs to `LinkTabs`
- update notifications, IdP sync, and workspace build pages to use
semantic tabs
- preserve route navigation tabs for groups and templates
- add stories/tests for both tab implementations
- simplify related layout and styling in touched components
2026-04-02 03:45:20 +11:00
Jake Howell 4a363b0d85 fix: resolve <Alert /> button poor visibility (#22597)
Closes #22244

This pull-request makes our `<Alert />`'s more inline with the Figma
style-system, we're looking to ensure that these are vertically rendered
now and not horizontal WCAG nightmares.

---------

Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
2026-04-01 16:41:45 +00:00
Kyle Carberry 7dc81bdef1 fix(site): fix sticky user message clipping and fade-in behavior (#23928)
The sticky user message in the chat timeline had two visual issues:

1. **Dead space during scroll** — the clipping calculation subtracted
48px prematurely (`fullHeight - scrolledPast - 48`), causing the message
to shrink before its content had actually left the viewport. Removed the
offset so clipping begins exactly when content scrolls out of view.

2. **Blur/gradient popping in abruptly** — the `--fade-opacity` variable
was a binary 0/1 toggle. Now it ramps 0→1 over the last 40px before
`MIN_HEIGHT`, so the blur and bottom gradient only appear when the
message is fully compressed.

Also added a longer (~25 line) user message to the `WithMessageHistory`
story to make the sticky behavior easier to test visually.
2026-04-01 16:33:51 +00:00
Kyle Carberry ee855f9618 feat: make agent context paths configurable via env vars (#23878)
Replace hardcoded paths for instruction files, skills, and MCP config
with
values read from `CODER_AGENT_EXP_*` environment variables. Template
authors
configure paths via the existing `coder_agent` `env` block. The agent
resolves `~`, relative, and absolute paths locally, then serves the
resolved config over `GET /api/v0/context-config`. `chatd` fetches this
once per workspace attach and falls back to today's defaults for older
agents.

All path env vars are comma-separated, allowing multiple directories:

| Env Var | Default | Controls |
|---|---|---|
| `CODER_AGENT_EXP_INSTRUCTIONS_DIRS` | `~/.coder` | Dirs containing the
instruction file |
| `CODER_AGENT_EXP_INSTRUCTIONS_FILE` | `AGENTS.md` | Instruction file
name |
| `CODER_AGENT_EXP_SKILLS_DIRS` | `.agents/skills` | Skills directories
|
| `CODER_AGENT_EXP_SKILL_META_FILE` | `SKILL.md` | Skill metadata file
name |
| `CODER_AGENT_EXP_MCP_CONFIG_FILES` | `.mcp.json` | MCP config files |

### Example

```hcl
resource "coder_agent" "main" {
  os   = "linux"
  arch = "amd64"
  env = {
    CODER_AGENT_EXP_INSTRUCTIONS_DIRS  = "/opt/company/agent-config,~/.coder"
    CODER_AGENT_EXP_INSTRUCTIONS_FILE  = "CLAUDE.md"
    CODER_AGENT_EXP_SKILLS_DIRS        = "/opt/company/ai-skills,.agents/skills"
    CODER_AGENT_EXP_MCP_CONFIG_FILES   = "/opt/company/mcp.json,.mcp.json"
  }
}
```

<details>
<summary>Implementation Details</summary>

### Architecture

Follows the same pattern as MCP tool discovery:
agent resolves locally → exposes via HTTP → chatd consumes.

**Agent-side** (`agent/agentcontextconfig/`):
- `ResolvePath` / `ResolvePaths` handle `~`, relative, and absolute path
forms; returns `""` for relative paths when baseDir is empty
- `Config` reads env vars, falls back to defaults, resolves all paths
- `GET /api/v0/context-config` serves the resolved config as JSON

**chatd-side** (`coderd/x/chatd/`):
- Calls `conn.ContextConfig()` once on first workspace attach
- Falls back to hardcoded defaults on 404 (older agents)
- Iterates instruction dirs, skills dirs using resolved absolute paths
- `LSRelativityRoot` everywhere — no more home/root juggling

### Key design decisions

- **`EXP_` prefix**: env vars use `CODER_AGENT_EXP_*` to indicate
experimental status
- **Plural names**: comma-separated vars use plural names (`DIRS`,
`FILES`); single-value vars use singular (`FILE`)
- **Defaults in `workspacesdk`**: default constants live in
`codersdk/workspacesdk/` so both agent and server reference them without
cross-layer imports
- **`skillMetaFile` persistence**: stored on context-file parts via
`ContextFileSkillMetaFile` and restored on subsequent chat turns so
custom values survive across turns
- **Working dir dedup**: `slices.Contains` guard prevents reading the
same instruction file from both `InstructionsDirs` and the working
directory
- **MCP server dedup**: first-occurrence-wins dedup prevents leaking
duplicate connections from overlapping config files
- **ResolvePath safety**: returns `""` for relative paths when `baseDir`
is empty, so `ResolvePaths` filters them out

### Files changed

| File | Change |
|---|---|
| `agent/agentcontextconfig/` | New package — path resolution + HTTP
endpoint |
| `codersdk/workspacesdk/agentconn.go` | `ContextConfigResponse` type,
default constants, client method |
| `agent/agent.go` + `agent/api.go` | Wire up endpoint, pass config to
MCP |
| `agent/x/agentmcp/manager.go` | Accept `[]string` MCP config paths,
dedup by name |
| `coderd/x/chatd/chatd.go` | Fetch config, thread through, named
returns |
| `coderd/x/chatd/instruction.go` | Accept configurable dir + file name,
`skillMetaFileFromParts` |
| `coderd/x/chatd/chattool/skill.go` | Accept configurable dirs + meta
file |
| `codersdk/chats.go` | `ContextFileSkillMetaFile` field for persistence
|

### Test coverage

- `TestConfig` (4 cases): defaults, custom env vars, whitespace
trimming, comma-separated dirs
- `TestResolvePath` / `TestResolvePaths`: including empty baseDir edge
case
- `TestPersistInstructionFilesFallbackOnOlderAgent`: backward-compat
path when `ContextConfig` returns 404
- `TestChatMessagePartVariantTags`: updated exclusion list for new
internal field

### Backward compatibility

Older agents return 404 for the new endpoint. `chatd` catches this and
falls back to today's defaults via `readHomeInstructionFile` (using
`LSRelativityHome`). Existing workspaces work with no changes.

</details>
2026-04-01 12:28:47 -04:00
Cian Johnston b1c42bb630 fix(site/src/pages/AgentsPage): use chat ID as terminal reconnection token (#23926)
The terminal panel in the agents sidebar generated a fresh
`reconnectionToken` via `crypto.randomUUID()` on every mount. Navigating
between chats or reloading the page orphaned the PTY session.

- Use the chat ID (`agentId`) as the reconnection token for
`TerminalPanel`
- Add optional `chatId` prop to `TerminalPanel`, falling back to a
random UUID when not provided
- Thread `agentId` from `AgentChatPageView` to `TerminalPanel`

This mirrors how the dedicated Terminal page persists sessions via a
URL-stored token.

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-01 16:22:10 +00:00
Danielle Maywood c048a4093e fix(site/src/pages/AgentsPage): persist file-reference chips across chat navigation (#23854) 2026-04-01 17:07:58 +01:00
Max Schwenk 1cc23a3144 fix(cli): allow multiple depends-on args in coder exp sync want (#23869)
Previously the command required exactly two arguments, forcing users to
run it multiple times to declare multiple dependencies for a single
unit.
This accepts variadic depends-on arguments so all dependencies can be
declared in one call:

```
coder exp sync want my-unit dep-1 dep-2 dep-3
```

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Marcin Tojek <mtojek@users.noreply.github.com>
2026-04-01 15:55:32 +00:00
Danielle Maywood dee5ec51c0 fix(site): prevent WebSocket events from cancelling sidebar pagination fetches (#23845) 2026-04-01 16:38:59 +01:00
Danielle Maywood e3c59c00cd fix(site): clear stream state atomically with durable message commit (#23924) 2026-04-01 16:38:16 +01:00
Mathias Fredriksson ba734f8b10 fix(site/src/pages/AgentsPage): fix copy button toolbar regression and add missing story coverage (#23912)
Move !isSavingMessage to the outer toolbar guard so the gradient
container does not mount empty during save. Remove the now-redundant
inner guard.

Add flex to the assistant copy button wrapper div. The plain block
wrapper with an inline-flex button created a line box whose height
depended on the inherited non-integer line-height (14px * 1.625 =
22.75px strut). Sub-pixel rounding during hover repaints caused a
1px jitter. Making it a flex container eliminates the strut.

Add behavioral assertions to UserMessageCopyButton: click edit and
assert onEditUserMessage fires, click copy and assert writeText is
called with the raw markdown.

Add MultiAssistantTurnCopyButton regression story for the
isLastAssistantMessage fix.

Refs #23850
2026-04-01 18:27:27 +03:00
Danielle Maywood 28062862a0 chore(site): upgrade to Vite 8 (#23485) 2026-04-01 15:11:47 +00:00
Cian Johnston 129e3509a3 fix(site): address post-merge review comments on kyleosophy chimes (#23896)
Fixes issues found in post-merge review of #23891 and #23892.

- **P2:** Export `_resetForTesting()` from `chime.ts` to break
cross-test cache dependency; call in `beforeEach`
- **P2:** Add `KylesophyToggle` and `TogglesKyleosophy` Storybook
stories
- **P3:** Fix JSDoc on `maybePlayChime` — terminal states are
`waiting|pending`, not `waiting|error`
- **P3:** Rename `setKylesophyLocal` back to `setLocalKyleosophy` to
match `setLocal*` convention
- Preserve original `location` property descriptor in
`isKylesophyForced` tests to avoid leaking mutated descriptors across
test suites (#23892 review)

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-01 15:40:10 +01:00
Atif Ali 53a1b6d67e ci: fix Linear release tracking and move complete step to release workflow (#23771) 2026-04-01 19:35:16 +05:00
Kyle Carberry 8c8b307b97 fix: persist session cookie to disk to prevent PWA logout (#23746) 2026-04-01 09:54:59 -04:00
Thomas Kosiewski 12f87acad6 feat(site): add terminal panel to chat sidebar (#23231)
## Add terminal panel to chat sidebar

Extract the reusable terminal runtime from `TerminalPage` into
`modules/terminal/` and wire it into the agents chat right sidebar as a
new **Terminal** tab.

### Changes

- **`modules/terminal/WorkspaceTerminal.tsx`** — Shared xterm +
websocket terminal component (container-sized, no route dependency)
- **`modules/terminal/WorkspaceTerminalAlerts.tsx`** — Moved from
`TerminalPage/` to shared module
- **`pages/AgentsPage/TerminalPanel.tsx`** — Sidebar wrapper around
`WorkspaceTerminal`
- **`pages/AgentsPage/AgentDetailView.tsx`** — Terminal tab added (gated
on `hasWorkspace`)
- **`pages/TerminalPage/TerminalPage.tsx`** — Slimmed to page-shell
using shared component

### Demo


[dogfood-terminal-demo.webm](https://github.com/user-attachments/assets/359200dc-f8e4-4a9a-b00b-923f142dc228)

### Behavior

- Terminal tab appears only when the chat has a workspace with a
connected agent
- Connects via the existing workspace agent PTY websocket
- Resizes correctly on panel width changes, expand/collapse, and
viewport resize
- `fitAddon.fit()` guarded against pre-renderer crashes (fixes proxy
access)
- Tab switching unmounts/remounts cleanly (reconnects via session token)
- No changes to Git or Desktop panel behavior

---

_Generated with [`mux`](https://github.com/coder/mux) • Model:
`anthropic:claude-opus-4-6` • Thinking: `xhigh`_
2026-04-01 13:38:23 +00:00
Cian Johnston 7198f9040d fix: rename user-facing 'chats' to 'Coder Agents' (#23905)
Refs #23897

- Rename user-facing "chats" to "Coder Agents" (feature name) or
"conversations" (individual instances)
- Covers UI strings, docs prose, Storybook stories, and aria labels
- API paths, internal code identifiers, and the "Chats API" docs page
name are intentionally left unchanged
- TaskPage / AI Tasks are out of scope

> 🤖 Written by a Coder Agent. Will be reviewed by a human.
2026-04-01 14:30:04 +01:00
Ethan ddafdbcbce fix(site): use imperative setValue for chat message editing instead of key-based remount (#23799)
Sometimes clicking **Edit** on a chat message does not populate the
composer with the message text, and the edit flow had a few timing bugs
around Lexical hydration. The composer was relying on
`key={initialValue}` on `LexicalComposer`, so re-editing the same text
could produce no state change, no remount, and an empty editor.

This PR keeps the editor mounted and switches edit flows to an
imperative `setValue()` API on `ChatMessageInputRef`. It also hardens
that API so draft reads and writes stay correct across initial
hydration: canceling edit no longer refocuses on mobile, pre-edit draft
snapshots preserve persisted drafts, early `setValue()` calls buffer
until the editor is ready, and `getValue()` falls back before readiness
but reads live editor state after attach.
2026-04-02 00:22:33 +11:00
Mathias Fredriksson 196dc51edf feat(site/src/pages/AgentsPage): add copy message button to chat messages (#23850)
Add a hover-reveal copy button to both user and assistant messages
in the agents chat. Copies raw markdown to the clipboard, preserving
formatting for pasting into markdown-aware editors.

The button uses the existing useClipboard hook and matches the
visual pattern established by the edit button on user messages
(opacity-0 with group-hover reveal and focus-visible support).

For assistant messages, the button sits below the response content.
For user messages, it sits inline alongside the edit button.
Messages with no copyable text content (e.g. tool-only messages)
do not show the button.
2026-04-01 16:08:25 +03:00
Cian Johnston 2a51687ff3 fix: stop amputating RC suffixes from docs URLs (#23903)
Fixes #23897 (docs link only — naming rename is in #23905)

- Fix version stripping logic in both Go (`codersdk/deployment.go`) and
TypeScript (`site/src/utils/docs.ts`) to preserve `-rc.X` suffixes
instead of amputating them along with `-devel`
- Add `v0.0.0` fallback in the TS frontend to match Go backend behavior
for dev builds
- Add tests covering RC, devel, and plain release version strings

> 🤖 Written by a Coder Agent. Will be reviewed by a human.
2026-04-01 13:05:14 +00:00
Kyle Carberry 19e44f4136 fix: target specific chat in MarkStale instead of broadcasting to all workspace chats (#23883)
## Problem

Subagent chats were receiving git context (branch, remote origin, PR
status) from their parent or sibling chats' git operations. When a git
operation triggers external auth, the workspace agent sends `chat_id`
identifying which chat initiated it — but this was broken at two levels:

1. **Agent side:** `CODER_CHAT_ID` was never injected into process
   environments. `chatd` sets `Coder-Chat-Id` HTTP headers and the
   agent extracts them for process isolation, but never propagated
   `CODER_CHAT_ID` to `cmd.Env`. So `gitaskpass` always sent an empty
   `chat_id`.

2. **Server side:** `workspaceAgentsExternalAuth` ignored the `chat_id`
   query param. `MarkStale` broadcast git context to **all** chats on
   the workspace via `filterChatsByWorkspaceID`.

## Fix

- Inject `CODER_CHAT_ID` into `cmd.Env` in `agentproc` when the chat
  ID is known, so `gitaskpass` can read and forward it.
- Read `chat_id` from query params in `workspaceAgentsExternalAuth`
  and thread it through `chatGitRef`.
- Refactor `MarkStale` to accept a `MarkStaleParams` struct. When
  `ChatID` is provided, target only that specific chat. When empty
  (legacy agents, non-chat git operations), fall back to the existing
  workspace-wide broadcast.
- Extract `markStaleSingle` helper to deduplicate the upsert+publish
  logic.

<details><summary>Investigation notes</summary>

### Data flow before fix

```
chatd → sets Coder-Chat-Id header on agent conn
agent → extracts chatID, stores on process struct
agent → does NOT set CODER_CHAT_ID in cmd.Env  ← gap 1
gitaskpass → reads CODER_CHAT_ID (always empty), sends chat_id=""
server handler → ignores chat_id query param     ← gap 2
MarkStale → broadcasts to ALL workspace chats
```

### Data flow after fix

```
chatd → sets Coder-Chat-Id header on agent conn
agent → extracts chatID, stores on process struct
agent → sets CODER_CHAT_ID in cmd.Env
gitaskpass → reads CODER_CHAT_ID, sends chat_id=<uuid>
server handler → reads chat_id, passes to MarkStale
MarkStale → targets only that specific chat
```

</details>
2026-04-01 13:04:59 +00:00
Kyle Carberry 2ea89e1f1b fix(site/src/pages/AgentsPage): show Thinking indicator immediately after sending a message (#23904)
After sending a message, `handleSend` clears stream state and inserts
the user message but did not set `chatStatus` to `"running"`. Combined
with #23805 narrowing `selectIsAwaitingFirstStreamChunk` to only
match `chatStatus === "running"` (instead of `isActiveChatStatus` which
included `"pending"`), the "Thinking..." indicator could not appear
until
the WebSocket delivered `status:running` — a 50–500ms+ gap.

Optimistically set `chatStatus` to `"running"` in both the send and edit
paths after the POST returns (non-queued). The WebSocket
`status:running`
event no-ops via the `setChatStatus` guard; error/pending events
override
the optimistic value.

<details><summary>Investigation & decision log</summary>

### Root cause chain

1. **PR #23805** (`953c3bdc0`) changed
`selectIsAwaitingFirstStreamChunk`
from `isActiveChatStatus(state.chatStatus)` → `state.chatStatus ===
"running"`.
Valid fix: during `"pending"`, `shouldApplyMessagePart()` drops stream
parts,
so `streamState` stays null and the 15s "startup taking too long"
warning
   fired spuriously during multi-turn tool-call cycles.

2. **PR #23884** (`4b5265695`) fixed event ordering within a WebSocket
batch
so both `[message_part, status:running]` and `[status:running,
message_part]`
orderings show "Thinking...". Correct fix, but only operates **after**
   `chatStatus` reaches `"running"`.

3. `handleSend` never set `chatStatus` optimistically — it relied
entirely on
the WebSocket `status:running` event. After #23805 narrowed the
selector,
   the gap between POST completion and WebSocket event became visible.

### Why this fix is safe

- Non-queued POST = server accepted the message → `"running"` is the
correct
  next state.
- `setChatStatus("running")` guard: `if (state.chatStatus === status)
return`
  makes the subsequent WebSocket confirmation a no-op.
- If the server transitions to error/pending instead, the WebSocket
event
  overrides the optimistic value.
- `shouldApplyMessagePart()` returns `true` for `"running"`, so early
stream
parts arriving before the WebSocket `status:running` will not be
silently
  dropped.

### What was NOT regressed by PR #23884

PR #23884's `setTimeout(0)` deferred flush is correct. Both event
orderings
now produce a render cycle where `chatStatus === "running"` and
`streamState === null`, allowing "Thinking..." to appear. The
`setTimeout(0)`
fires in a separate macrotask, giving the browser a paint opportunity.

</details>
2026-04-01 12:57:18 +00:00
Danielle Maywood faa5db0cf0 refactor(site/src/pages/AgentsPage): replace ScrollAnchoredContainer with useStickToBottom (#23846) 2026-04-01 13:56:04 +01:00
Jake Howell 3758b02595 fix: resolve <WorkspacePage /> colors (#23902)
This pull-request ensures that our borders and content are all inline
with the design-system, whilst also ripping out the old Material UI
based design system. Furthermore, we're enforcing the background
gradient to always be showing regardless of if `<ResourceMetadata />`
has content.

| Old | New |
| --- | --- |
| <img width="1624" height="1061" alt="image"
src="https://github.com/user-attachments/assets/0accc324-b012-43e4-bb13-ec3629fbc909"
/> | <img width="1624" height="1061" alt="image"
src="https://github.com/user-attachments/assets/e89c752a-057c-4256-9f8e-728d1f89a1fd"
/> |
2026-04-01 23:28:59 +11:00
Kyle Carberry 7861fcf1f6 perf(coderd): stop inline-resolving diff status on every GetChat call (#23901)
## Problem

Every `GET /api/experimental/chats/{chatID}` call was blocking for
200-800ms because the `getChat` handler called `resolveChatDiffStatus`,
which unconditionally hit the git provider API (e.g. GitHub's `GET
/repos/{owner}/{repo}/pulls?head=...`) via `ResolveBranchPullRequest` —
even when the cached diff status was fresh.

This made every chat page load at `/agents/{id}` noticeably slow.

## Root cause

The call chain was:
1. `getChat` → `resolveChatDiffStatus`
2. `resolveChatDiffStatus` → `resolveChatDiffReference` →
`gp.ResolveBranchPullRequest(...)` **(external HTTP call)**
3. Only **after** the external call: `chatDiffStatusIsStale(status,
now)` check

The staleness check happened after the expensive work, so every request
paid the cost regardless of cache freshness.

## Fix

`getChat` now returns the cached `chat_diff_statuses` row directly from
the database. The background `gitsync` worker already keeps these rows
fresh (every `DiffStatusTTL = 120s`), so inline resolution was
redundant.

The `resolveChatDiffContents` endpoint (which fetches actual diff
content) still uses the full resolution path since it needs to make
provider API calls by design.

## Changes

- `getChat` reads cached diff status from DB instead of calling
`resolveChatDiffStatus`
- Remove `resolveChatDiffStatus` (dead code — no production callers)
- Remove `chatDiffStatusIsStale` and `chatDiffStatusTTL` (dead code)
- Remove `RefreshesStaleStatusWithExternalAuth` test (tested the removed
inline refresh path)

<details><summary>Decision log</summary>

- **Why not just add a staleness gate?** The background worker already
handles refreshes on the same schedule. Adding an early-return-if-fresh
would work but leaves dead code for the stale path that's never
exercised in production (the worker gets there first). Removing the
inline path entirely is simpler and eliminates the external API
dependency from the read path.
- **Why keep `resolveChatDiffContents` unchanged?** That endpoint's job
is to fetch the actual diff content from the provider, so external API
calls are inherent to its purpose.

</details>
2026-04-01 12:08:13 +00:00
Kyle Carberry 4b52656958 fix(site): ensure Thinking indicator appears regardless of WebSocket event ordering (#23884)
The "Thinking..." indicator intermittently failed to render after
submitting a message. The behavior depended on the order of events
within a single WebSocket frame.

## Root Cause

`flushMessageParts()` was called before **all** non-`message_part`
events in the batch loop. When the server sent `[message_part,
status:"running"]` in the same SSE chunk:

1. `message_part` → pushed to `partsBuf`
2. `status:"running"` → `flushMessageParts()` applied parts **first** →
`streamState` became non-null → then `chatStatus` set to `"running"`
3. Subscriber saw `streamState != null && chatStatus == "running"` →
`selectIsAwaitingFirstStreamChunk` returned `false` → no "Thinking..."

When events arrived in the reverse order (`[status:"running",
message_part]`), the indicator worked because the status was set before
parts were applied.

## Fix

- Move `flushMessageParts()` to only fire before `message` and `error`
events (which need prior parts visible)
- Add `discardBufferedParts()` for events that clear stream state
(`status:"pending"/"waiting"`, `retry`) so the deferred `setTimeout(0)`
flush doesn't re-populate cleared state
- Status changes are now always applied before parts within a batch, and
the deferred flush gives React one render cycle to show "Thinking..."

| Event | Flush? | Rationale |
|---|---|---|
| `message` | YES | Durable commit must include all stream parts |
| `error` | YES | Partial output should be visible alongside error |
| `status` | NO | Status must be set before parts so "starting" phase
renders |
| `retry` | DISCARD | Retry clears stream state; flushing would
re-populate it |
| `queue_update` | NO | Doesn't interact with stream state |

## Tests (written first, failing before fix)

1. **"shows starting phase when message_part arrives before
status:running in same batch"** — the exact bug scenario
2. **"shows starting phase when status:running arrives before
message_part in same batch"** — verifies the "good" order still works
3. **"discards buffered parts when status transitions to pending"** —
verifies parts don't leak through pending transitions

All tests are deterministic (fake timers, no race conditions).

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

### Why not reorder events within the batch?
Reordering would change the semantic ordering of events from the server,
which could have subtle side effects. The simpler approach is to be
selective about when parts are flushed.

### Why discard (not flush) before pending/waiting/retry?
These events clear `streamState`. If parts were flushed before the
clear, they'd be visible for one frame then disappear. If the deferred
flush ran after the clear, it would re-populate the state. Discarding is
the only correct behavior.

### Why keep flush before error?
Errors should surface partial output so the user can see what the agent
was doing when it failed.

</details>
2026-04-01 07:45:38 -04:00
Danielle Maywood f5b98aa12d fix: stabilize flaky visual stories (#23893) 2026-04-01 11:51:06 +01:00
Cian Johnston 7ddde0887b feat(site): force-enable kyleosophy on dev.coder.com (#23892)
- Force-enable Kyleosophy on `dev.coder.com` via hostname check
- Toggle shows as checked + disabled with "Kyleosophy is mandatory on
`dev.coder.com`"
- `isKylesophyForced()` exported for UI and testability
- Tests for forced/non-forced hostname behavior

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-01 11:39:04 +01:00
Cian Johnston bec426b24f feat(site): add Kyleosophy alternative completion chimes (#23891)
- Add "Enable Kyleosophy" toggle to Settings > Behavior
- When enabled, replaces standard completion chime with random Kyle
sound clips
- Ships 8 alternative `.mp3` files as static assets (~82KB total)
- localStorage preference (`agents.kyleosophy`), defaults to off
- Pauses orphaned Audio elements on sound URL change to prevent overlap

<details><summary>Review findings addressed</summary>

- **P2** Stale JSDoc on `playChimeAudio` — updated to reflect
parameterized behavior
- **P3** Overlapping audio on rapid completions — added
`chimeAudio?.pause()` before replacement
- **P3** Test ordering dependency — pinned `Math.random` for
determinism, documented cache behavior
- **Nit** Setter naming — `setLocalKyleosophy` → `setKylesophyLocal`
- Toggle moved to bottom of Behavior page per product request
- Description changed to "IYKYK" per product request

</details>

> 🤖 Written by a Coder Agent. Reviewed by a human.
2026-04-01 10:06:01 +00:00
Cian Johnston d6df78c9b9 chore: remove racy ChatStatusPending assertions after CreateChat (#23882)
Removes 6 fragile `require.Equal(t, codersdk.ChatStatusPending,
chat.Status)` assertions from chat relay and creation tests.

**Root cause**: In HA tests with two replicas sharing the same DB, the
worker can acquire a just-created chat (flipping `pending → running` via
`AcquireChats`) before the HTTP response reaches the test. All affected
tests already synchronize via `require.Eventually` waiting for `running`
status, making the initial assertion both redundant and racy.

- Remove 5 assertions in `enterprise/coderd/exp_chats_test.go` (all
`TestChatStreamRelay` subtests)
- Remove 1 assertion in `coderd/exp_chats_test.go` (`TestPostChats`)
- An existing comment in `TestPostChats/Success` already documents this
exact race

Fixes flake:
https://github.com/coder/coder/actions/runs/23807597632/job/69385425724

> 🤖 Written by a Coder Agent. Will be reviewed by a human.
2026-04-01 10:00:50 +01:00
Danielle Maywood 19390a5841 fix: resolve TestScheduleOverride/extend flake caused by timezone hour boundary race (#23830) 2026-04-01 07:53:04 +01:00
Jake Howell 2d03f7fd3d fix: resolve rendering issues with GFM alert boxes in <DynamicParameter /> (#22241)
Closes #22189

GFM alerts (e.g., `> [!IMPORTANT]`) in Markdown content failed to render
when the alert body contained inline formatting like `**bold**`,
`*italic*`, or `` `code` ``. The alert marker and subsequent text were
merged into a single string node by the parser, causing the type
detection to fail and fall back to a plain blockquote.

Additionally, multi-line alert content (`> line one\n> line two`) lost
its line breaks — all lines collapsed into one.

- Split the alert marker from trailing content in shared string nodes so
type detection works with inline formatting
- Preserve embedded newlines as `<br/>` elements to match GitHub's GFM
alert rendering
- Wrap plain-text children instead of splitting on `\n` to avoid
stripping newline information early

<img width="447" height="187" alt="image"
src="https://github.com/user-attachments/assets/d2fa3495-0b31-483c-97d8-12fed6819e24"
/>
2026-04-01 17:20:55 +11:00
Ethan 153a66b579 fix(site/src/pages/AgentsPage): confirm active agent archive (#23887)
Add a confirmation dialog before archiving an agent that is actively
running from the Agents UI.

This PR came about as feedback on PR 23758:
https://github.com/coder/coder/pull/23758#issuecomment-4160424938.
Active agents now require confirmation before archive interrupts the
current run, while inactive agents keep the existing one-click archive
behavior.

<img width="450" height="242" alt="image"
src="https://github.com/user-attachments/assets/98ce6978-d2d6-440b-9841-3806038556ee"
/>
2026-04-01 15:34:21 +11:00
Ethan 5cba59af79 fix(coderd): unarchive child chats with parents (#23761)
Unarchiving a root chat now restores descendant chats in the database
and emits lifecycle events for every affected chat so passive sessions
converge without a full refetch.

This keeps archive and unarchive symmetric at both the data and
watch-stream layers by returning the affected chat family from the
database, using those post-update rows for chatd pubsub fanout, and
covering descendant lifecycle delivery with a watch-level regression
test.

Closes #23666
2026-04-01 15:30:25 +11:00
Jeremy Ruppel 1d16ff1ca6 fix(site): sessions list and timeline polish (#23885)
- Prompt table was collapsing and sizing improperly, fixed 
- Make pretty much everything `text-sm` and `font-normal`
- Add model filter
- Back button on session threads page now navigates back instead of
going straight to `/aibridge/sessions`

---------

Co-authored-by: Jake Howell <jake@hwll.me>
2026-04-01 14:42:08 +11:00
Ethan b86161e0a6 test: fix TestServer_X11_EvictionLRU hang on fish shell (#23838)
`TestServer_X11_EvictionLRU` hangs forever when the developer's login
shell is `fish`. This is the only test in the repo that breaks on fish,
and it meant I couldn't run `make test` or similar without it blocking
indefinitely.

The test uses `sess.Shell()` to start interactive shell sessions, which
causes the SSH server to run the user's login shell directly (`fish
-l`). Fish buffers all piped stdin to EOF before executing any of it, so
the test's `echo ready-0\n` write never gets processed — fish sits
waiting for the pipe to close, and the test sits waiting for the echo
response.

The fix is a one-line change: `sess.Shell()` → `sess.Start("sh")`. The
test is exercising X11 LRU eviction, not shell behavior, so using `sh`
explicitly is both correct and shell-agnostic. The DISPLAY environment
variable is set identically either way since the x11-req handler runs
before `sessionStart`.
2026-04-01 12:31:22 +11: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
Ethan 348a3bd693 fix(site): show archived filter on agents page when chat list is empty (#23793)
While running scaletests I noticed the archived filter button on the
agents sidebar would disappear when the current filter had zero results.
This made it impossible to switch between active and archived views once
one side was empty.

The filter dropdown was only rendered inside the Pinned or first
time-group section header. When `visibleRootIDs` was empty, neither
header existed, so the filter had nowhere to attach.

This keeps the original dropdown placement on section headers when chats
exist. When the list is empty, the empty-state box itself now provides a
"View archived →" or "← Back to active" link so users can always switch
filters without needing the dropdown.

<img width="322" height="191" alt="image"
src="https://github.com/user-attachments/assets/7fd9ca09-5f72-4796-a925-7fab570fdff5"
/>

<img width="320" height="184" alt="image"
src="https://github.com/user-attachments/assets/2f856088-c2dc-4e34-9ece-84144a1adf79"
/>

Both archived & unarchived look the same when there's at least one
agent:

<img width="322" height="194" alt="image"
src="https://github.com/user-attachments/assets/42c4d54b-e500-45b1-b045-c126144c35bd"
/>
2026-03-31 12:57:46 +11:00
Jeremy Ruppel 75f1503b41 feat(site): various Session Timeline fixes (#23791)
- Use Tooltip instead of Popover for AI Gov tooltip
- Fix Agentic Loop tool call summing
- Collapse all expandable sections by default
- Add solid background to "Show More" button
- Remove "Sort by" dropdown for v1
2026-03-30 19:25:03 -04:00
Danielle Maywood c33cd19a05 fix(site/scripts): guard check-compiler main block from test imports (#23825) 2026-03-30 22:11:15 +01:00
Danielle Maywood adcea865c7 fix(site): improve check-compiler.mjs quality and fix bugs (#23812) 2026-03-30 20:41:41 +00:00
Matt Vollmer 5e3bccd96c docs: fix tool tables and model option errors in agent docs (#23821)
Fixes factual errors found during a review of all pages under
`/docs/ai-coder/agents/`.

## Tool tables (`index.md`, `architecture.md`)

Both pages had incomplete tool tables. Added:

- `process_output`, `process_list`, `process_signal` — core workspace
tools always registered alongside `execute`, missing from both pages
- `propose_plan` — platform tool (root chats only), missing from both
pages
- `spawn_computer_use_agent` — orchestration tool (conditional), missing
from architecture.md

Also fixed the architecture.md claim that the agent is "restricted to
the tool set defined in this section" — it now mentions skills and MCP
tools with links to the relevant pages.

## Model options (`models.md`)

- **OpenAI / OpenRouter Reasoning Effort**: docs listed `low`, `medium`,
`high` — code has `none`, `minimal`, `low`, `medium`, `high`, `xhigh`.
Fixed both.
- **Removed hidden fields** that never appear in the admin UI:
  - Google: Safety Settings (`hidden:"true"`)
- OpenRouter: Provider Order, Allow Fallbacks (parent struct
`hidden:"true"`)
  - Vercel: Provider Options (`hidden:"true"`)

---

*PR generated with Coder Agents*
2026-03-30 16:24:45 -04:00
Mathias Fredriksson 3950947c58 fix(site): prevent scroll handler from killing autoScroll during pin convergence (#23818)
WebKit internally adjusts scrollTop during layout when content
above the viewport changes height, even with overflow-anchor:none.
These phantom adjustments fire scroll events where isNearBottom
returns false. The scroll handler was setting autoScrollRef =
nearBottom on every such event, permanently killing follow mode.

The scroll handler now only enables follow mode, never disables
it. When follow mode is active, the user is not wheel/touch
scrolling, and isNearBottom is false, this indicates a
browser-initiated scroll adjustment. Re-pin immediately and
set the restore guard so the pin's own scroll event is suppressed.

Disabling follow mode is exclusive to user-interaction handlers
(wheel, touch, scrollbar pointerdown) via handleUserInterrupt.

Guard-clear callbacks also check isNearBottom before dropping
the restoration flag, re-pinning if content grew between the
pin and the clear.
2026-03-30 22:45:58 +03:00
Kyle Carberry b3d5b8d13c fix: stabilize flaky chatd subscribe/promote queued tests (#23816)
## Summary

Fixes three flaky chatd tests that intermittently fail due to timing
races with the background run loop.

Closes coder/internal#1428

## Root Cause

`CreateChat` and `PromoteQueued` call `signalWake()` which writes to
`wakeCh`, triggering `processOnce` immediately. Even though
`newTestServer` sets `PendingChatAcquireInterval: testutil.WaitLong` to
prevent ticker-based polling, the wake channel bypasses this. This
causes `processOnce` to acquire and process the chat concurrently with
the test's manual DB updates and assertions.

### Failing tests

| Test | Failure | Cause |
|------|---------|-------|
| `TestPromoteQueuedAllowsAlreadyQueuedMessageWhenUsageLimitReached` |
`expected: "pending", actual: "running"` | Wake from `CreateChat` races
with manual `UpdateChatStatus`; wake from `PromoteQueued` acquires the
chat before the status assertion |
| `TestSendMessageInterruptBehaviorQueuesAndInterruptsWhenBusy` |
`should have 1 item(s), but has 2` | Wake from `CreateChat` triggers
`processChat` which auto-promotes a queued message, adding an extra row
to `chat_messages` |
| `TestSubscribeNoPubsubNoDuplicateMessageParts` | `Condition satisfied`
(duplicate events) | Pre-existing `WaitGroup.Add/Wait` race in the
`Eventually` + `WaitUntilIdleForTest` pattern |

## Fix

Introduces a `waitForChatProcessed` helper that:
1. Polls until the chat reaches a **terminal state** (not pending AND
not running)
2. Then calls `WaitUntilIdleForTest` to wait for the inflight
`WaitGroup`

Waiting for a terminal state (not just "not pending") avoids a
`sync.WaitGroup` `Add/Wait` race: `AcquireChats` updates the DB status
to `running` **before** `processOnce` calls `inflight.Add(1)`. Checking
only `status != pending` could return while `Add(1)` hasn't happened
yet, causing `Wait()` to return prematurely.

### Per-test changes

- **`TestSendMessageInterruptBehaviorQueuesAndInterruptsWhenBusy`**:
Call `waitForChatProcessed` after `CreateChat` before manually setting
running status
-
**`TestPromoteQueuedAllowsAlreadyQueuedMessageWhenUsageLimitReached`**:
Call `waitForChatProcessed` after `CreateChat`; remove the inherently
racy `status == pending` assertion after `PromoteQueued` (the wake
immediately acquires the chat). Key assertions on promoted message,
queue state, and message count remain.
- **`TestSubscribeNoPubsubNoDuplicateMessageParts`**: Replace inline
`Eventually` with the safer `waitForChatProcessed` helper

## Verification

All three tests pass 150 consecutive executions with `-race -count=10`
across 15 runs (0 failures).
2026-03-30 18:23:47 +00:00
blinkagent[bot] a00afe4b5a chore(site): update proxy menu dialog text (#23765)
Updates the descriptive text in the proxy selection dropdown menu to be
clearer and more concise.

**Before:**
> Workspace proxies improve terminal and web app connections to
workspaces. This does not apply to CLI connections. A region must be
manually selected, otherwise the default primary region will be used.

**After:**
> Workspace proxies improve terminal and web app connections. CLI
connections are unaffected. If no region is selected, the primary region
will be used.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-03-30 11:20:05 -07:00
Kyle Carberry a5cc579453 feat: add last_injected_context column to chats table (#23798)
Adds a nullable JSONB column `last_injected_context` to the `chats`
table that stores the most recently persisted injected context parts
(AGENTS.md context-file and skill message parts). The column is updated
only when `persistInstructionFiles()` runs — on first workspace attach
or when the agent changes — so there are no redundant writes on
subsequent turns.

Internal fields (`ContextFileContent`, `ContextFileOS`,
`ContextFileDirectory`, `SkillDir`) are stripped at write time so the
column only holds small metadata. No stripping needed on the read path.

<details>
<summary>Implementation notes</summary>

- New migration `000456` adds nullable `last_injected_context JSONB`
column.
- New SQL query `UpdateChatLastInjectedContext` writes the column
without touching `updated_at`.
- `persistInstructionFiles()` strips internal fields from parts via
`StripInternal()` before persisting.
- Sentinel path (no AGENTS.md) persists skill-only parts when skills
exist.
- `codersdk.Chat` exposes `LastInjectedContext []ChatMessagePart`
(omitempty).
- `db2sdk.Chat()` passes through the already-clean data.

</details>
2026-03-30 14:11:30 -04:00
Spike Curtis ef3aade647 chore: support agent updates in tunneler (#23730)
<!--

If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting.

-->

relates to GRU-18

Adds support for agent updates to the Tunneler
2026-03-30 13:50:06 -04:00
dependabot[bot] 3cc31de57a chore: bump github.com/go-git/go-git/v5 from 5.17.0 to 5.17.1 (#23813)
Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git)
from 5.17.0 to 5.17.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/go-git/go-git/releases">github.com/go-git/go-git/v5's
releases</a>.</em></p>
<blockquote>
<h2>v5.17.1</h2>
<h2>What's Changed</h2>
<ul>
<li>build: Update module github.com/cloudflare/circl to v1.6.3
[SECURITY] (releases/v5.x) by <a
href="https://github.com/go-git-renovate"><code>@​go-git-renovate</code></a>[bot]
in <a
href="https://redirect.github.com/go-git/go-git/pull/1930">go-git/go-git#1930</a></li>
<li>[v5] plumbing: format/index, Improve v4 entry name validation by <a
href="https://github.com/pjbgf"><code>@​pjbgf</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1935">go-git/go-git#1935</a></li>
<li>[v5] plumbing: format/idxfile, Fix version and fanout checks by <a
href="https://github.com/pjbgf"><code>@​pjbgf</code></a> in <a
href="https://redirect.github.com/go-git/go-git/pull/1937">go-git/go-git#1937</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/go-git/go-git/compare/v5.17.0...v5.17.1">https://github.com/go-git/go-git/compare/v5.17.0...v5.17.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/go-git/go-git/commit/5e23dfd02db92644dc4a3358ceb297fce875b772"><code>5e23dfd</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1937">#1937</a>
from pjbgf/idx-v5</li>
<li><a
href="https://github.com/go-git/go-git/commit/6b38a326816b80f64c20cc0e6113958b65c05a1c"><code>6b38a32</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1935">#1935</a>
from pjbgf/index-v5</li>
<li><a
href="https://github.com/go-git/go-git/commit/cd757fcb856a2dcc5fff6c110320a8ff62e99513"><code>cd757fc</code></a>
plumbing: format/idxfile, Fix version and fanout checks</li>
<li><a
href="https://github.com/go-git/go-git/commit/3ec0d70cb687ae1da5f4d18faa4229bd971a8710"><code>3ec0d70</code></a>
plumbing: format/index, Fix tree extension invalidated entry
parsing</li>
<li><a
href="https://github.com/go-git/go-git/commit/dbe10b6b425a2a4ea92a9d98e20cd68e15aede01"><code>dbe10b6</code></a>
plumbing: format/index, Align V2/V3 long name and V4 prefix encoding
with Git</li>
<li><a
href="https://github.com/go-git/go-git/commit/e9b65df44cb97faeba148b47523a362beaecddf9"><code>e9b65df</code></a>
plumbing: format/index, Improve v4 entry name validation</li>
<li><a
href="https://github.com/go-git/go-git/commit/adad18daabddee04c5a889f0230035e74bca32c0"><code>adad18d</code></a>
Merge pull request <a
href="https://redirect.github.com/go-git/go-git/issues/1930">#1930</a>
from go-git/renovate/releases/v5.x-go-github.com-clo...</li>
<li><a
href="https://github.com/go-git/go-git/commit/29470bd1d862c6e902996b8e8ff8eb7a0515a9be"><code>29470bd</code></a>
build: Update module github.com/cloudflare/circl to v1.6.3
[SECURITY]</li>
<li>See full diff in <a
href="https://github.com/go-git/go-git/compare/v5.17.0...v5.17.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/go-git/go-git/v5&package-manager=go_modules&previous-version=5.17.0&new-version=5.17.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts page](https://github.com/coder/coder/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 17:27:38 +00:00
Mathias Fredriksson d2c308e481 fix(site/src/pages/AgentsPage): unify scroll restore-guard lifecycle in ScrollAnchoredContainer (#23809)
Two ResizeObserver effects (content and container) each had their own
local restoreGuardRafId but both wrote to the shared
isRestoringScrollRef. Either observer's guard-clear RAF could fire
while the other's pin chain was in-flight, leaving isRestoringScrollRef
prematurely false.

scrollTranscriptToBottom also set isRestoringScrollRef without
cancelling any pending guard-clear, so a stale clear could drop
the flag mid-smooth-scroll animation.

Promote restoreGuardRafId to a single shared ref so all write
paths coordinate through one cancellation point.
2026-03-30 20:17:44 +03:00
Kyle Carberry 953c3bdc0f fix(site): prevent spurious startup warning during pending status (#23805)
## Problem

The `/agents` page frequently shows "Response startup is taking longer
than expected" even while the agent is actively working and messages are
appearing in the transcript.

## Root Cause

There's an inconsistency between `isActiveChatStatus` and
`shouldApplyMessagePart` during `"pending"` status (the state between
agent tool-call turns):

| Component | Treats `"pending"` as... |
|---|---|
| `isActiveChatStatus` | **active** — includes both `"running"` and
`"pending"` |
| `shouldApplyMessagePart` | **inactive** — drops all `message_part`
events during `"pending"` |
| Status handler | clears `streamState` to `null` on `"pending"` |

This creates a dead state during multi-turn tool-call cycles:

1. Agent finishes a turn → status = `"pending"` → `streamState` cleared
to `null`
2. `selectIsAwaitingFirstStreamChunk` returns `true` (status is
"active", stream is null, latest message isn't assistant)
3. Phase = `"starting"` → 15s timer starts
4. Stream parts from the server are **silently dropped**
(`shouldApplyMessagePart()` returns `false` for `"pending"`)
5. `streamState` stays `null` — phase is stuck at `"starting"`
6. Meanwhile, durable messages (tool calls, tool results) appear
normally in the transcript
7. After 15s → "Response startup is taking longer than expected" fires

## Fix

Narrow `selectIsAwaitingFirstStreamChunk` to only check `chatStatus ===
"running"` instead of `isActiveChatStatus(chatStatus)`. `"running"` is
the only status where the transport actually accepts stream parts, so
it's the only status where we should be showing the "starting"
indicator.

`isActiveChatStatus` is left unchanged since its other caller
(`shouldSurfaceReconnectState`) correctly needs to include `"pending"`.
2026-03-30 12:46:51 -04:00
Matt Vollmer ca879ffae6 docs: add extending-agents, mcp-servers, and usage-insights pages (#23810)
Adds three new documentation pages for major shipped features that had
no docs, and updates the platform controls index to reflect current
state.

## New pages

### Extending Agents (`extending-agents.md`)

Covers two workspace-level extension mechanisms:
- **Skills** — `.agents/skills/<name>/SKILL.md` directory structure,
frontmatter format, auto-discovery, `read_skill`/`read_skill_file`
tools, size limits, lazy loading
- **Workspace MCP tools** — `.mcp.json` format, stdio and HTTP
transports, tool name prefixing, discovery lifecycle and caching

### MCP Servers (`platform-controls/mcp-servers.md`)

Admin MCP server configuration:
- CRUD via **Agents** > **Settings** > **MCP Servers**
- Four auth modes: none, OAuth2 (with auto-discovery), API key, custom
headers
- Availability policies: `force_on`, `default_on`, `default_off`
- Tool governance via allow/deny lists
- Permission model and secret redaction

### Usage & Insights (`platform-controls/usage-insights.md`)

Three admin dashboards:
- **Usage limits** — spend caps with per-user and per-group overrides,
priority hierarchy, enforcement behavior
- **Cost tracking** — per-user rollup with token breakdowns, date
filtering, per-model and per-chat drill-down

## Updated files

- **`platform-controls/index.md`** — Moved MCP servers, usage limits,
and analytics from "Where we are headed" into "What platform teams
control today" with links to the new pages. Removed the tool
customization roadmap section (now covered by MCP servers page).
- **`manifest.json`** — Added nav entries for all three new pages.

## Resulting nav hierarchy

```
Coder Agents
├── Getting Started
├── Early Access
├── Architecture
├── Models
├── Platform Controls
│   ├── Template Optimization
│   ├── MCP Servers              ← NEW
│   └── Usage & Insights         ← NEW
├── Extending Agents             ← NEW
└── Chats API
```

---

*PR generated with Coder Agents*
2026-03-30 12:46:34 -04:00
Cian Johnston 0880a4685b ci: fix pnpm not found in check-docs job (#23807)
- Enable corepack before the linkspector step so `pnpm` shim is in PATH
- `action-linkspector@v1.4.1` internally calls `actions/setup-node@v5`,
which now defaults `package-manager-cache: true` — it detects
`pnpm-lock.yaml` and tries to resolve the `pnpm` binary, but it's not
installed on the runner
- Add TODO to remove the workaround when upstream is fixed

Upstream: https://github.com/UmbrellaDocs/action-linkspector/issues/54

> 🤖 Cian asked a Coder Agent to make this PR and then reviewed the
change.
2026-03-30 21:28:51 +05:00
Danielle Maywood 3f8e3007d8 fix(site): write WebSocket messages to React Query cache (#23618) 2026-03-30 15:56:08 +01:00
Matt Vollmer 8e57498a87 docs: update Chats API and platform controls docs to match current state (#23803)
The Chats API docs and platform controls docs had fallen behind the
implementation. This brings them up to date.

## Chats API docs (`chats-api.md`)

### Breaking: archive/unarchive endpoints removed

The old `POST /{chat}/archive` and `POST /{chat}/unarchive` endpoints no
longer exist. Replaced with the `PATCH /{chat}` update endpoint
(`{"archived": true/false}`).

### Chat object updated

Added all new fields to the example response and a new reference table:
- `build_id`, `agent_id` — workspace agent binding
- `parent_chat_id`, `root_chat_id` — delegated/child chat lineage
- `pin_order` — pinned chats
- `labels` — general-purpose key-value labels
- `mcp_server_ids` — MCP server bindings
- `has_unread` — read/unread tracking
- `diff_status` — PR/diff metadata

### New endpoints documented

- `PATCH /{chat}` — update chat (title, archived, pin_order, labels)
- `PATCH /{chat}/messages/{message}` — edit a user message
- `GET /watch` — watch all chats via WebSocket
- `POST /{chat}/title/regenerate` — regenerate title
- `GET /{chat}/diff` — get diff/PR status
- `DELETE /{chat}/queue/{id}` / `POST /{chat}/queue/{id}/promote` —
queue management

### Updated existing endpoint docs

- Create chat: added `mcp_server_ids` and `labels` fields
- Send message: added `mcp_server_ids` field
- List chats: added `q` and `label` query parameters
- Stream: noted read cursor behavior on connect/disconnect

## Platform controls docs

### Template allowlist (`platform-controls/index.md`)

- Updated the "Template routing" section to document the template
allowlist setting (**Agents** > **Settings** > **Templates**)
- Removed the "Template scoping for agents" bullet from "Where we are
headed" since it shipped

### Template optimization (`template-optimization.md`)

- Added "Restrict available templates" section documenting the allowlist
UI, behavior, and scope (agents only, not manual workspace creation)

---

*PR generated with Coder Agents*
2026-03-30 10:28:15 -04:00
Susana Ferreira 0fb3e5cba5 feat: extract, log, and strip aibridgeproxy request ID header in aibridged (#23731)
## Problem

`aibridgeproxyd` sends `X-AI-Bridge-Request-Id` on every MITM request to
`aibridged` for cross-service log correlation, but aibridged never reads
it. The header is silently forwarded to upstream LLM providers.

## Changes

* Renamed the header to `X-Coder-AI-Governance-Request-Id` to match the
existing `X-Coder-AI-Governance-*` convention.
* `aibridged` now extracts the header, logs it and strips it before
forwarding upstream.
* Added `TestServeHTTP_StripInternalHeaders` to verify no `X-Coder-*`
headers leak to upstream
2026-03-30 15:21:30 +01:00
Mathias Fredriksson 7fb93dbf0e build: lock provider version in provisioner/terraform/testdata (#23776)
The terraform testdata fixtures silently drift when the coder provider
releases a new version. The .terraform.lock.hcl files are gitignored,
.tf files use loose constraints (>= 2.0.0), and generate.sh always
runs terraform init -upgrade. The Makefile only re-runs generate.sh
when the terraform CLI version changes, not the provider version.

Track a canonical lockfile and provider-version.txt in git. Change
generate.sh to respect the lockfile by default (terraform init without
-upgrade). Add --upgrade flag for intentional provider bumps, --check
for cheap staleness detection in the Makefile, and a new
update-terraform-testdata make target.
2026-03-30 16:37:25 +03:00
Michael Suchacz cf500b95b9 chore: move docker-chat-sandbox under templates/x (#23777)
Adds the experimental `docker-chat-sandbox` example template under
`examples/templates/x/`. It provisions a regular dev agent plus a
chat-designated agent that runs inside bubblewrap with a read-only root,
writable `/home/coder`, and outbound TCP restricted to the Coder
control-plane endpoint via `iptables`.

The chat agent still appears in dashboard and API responses, but the
template reserves it for chatd-managed sessions rather than normal user
interaction. `lint/examples` now walks nested template directories, so
experimental templates can live under `examples/templates/x/` without
treating `x/` itself as a template.
2026-03-30 15:17:55 +02:00
Danielle Maywood 6a2f389110 refactor(site/src/pages/AgentsPage): use createReconnectingWebSocket in git and workspace watchers (#23736) 2026-03-30 14:05:05 +01:00
Danielle Maywood 027f93c913 fix(site): make settings and analytics headers scrollable in Safari PWA (#23742) 2026-03-30 13:55:35 +01:00
Hugo Dutka 509e89d5c4 feat(site): refactor the wait for computer use subagent card (#23780)
Right now, when an agent is waiting for the computer use subagent, it
shows a VNC preview of the desktop that spans the full width of the
chat. It also displays a standard "waiting for <subagent name>" header
above it. See https://github.com/coder/coder/pull/23684 for a recording.

This PR refactors that preview to be smaller and changes the header to a
shimmering "Using the computer" label.


https://github.com/user-attachments/assets/0db5b4dc-6899-419b-bf7f-eb0de05722f1
2026-03-30 14:51:14 +02:00
Mathias Fredriksson 378f11d6dc fix(site/src/pages/AgentsPage): fix scroll-to-bottom pin starvation in agents chat (#23778)
scheduleBottomPin() cancelled any in-flight pin and restarted the
double-RAF chain on every ResizeObserver notification. When content
height changes on consecutive frames (e.g. during streaming where
SmoothText reveals characters each frame and markdown re-rendering
occasionally changes block height), the inner RAF that actually sets
scrollTop is perpetually cancelled before it fires. The scroll falls
behind the growing content.

Two fixes:

1. Make scheduleBottomPin() idempotent: if a pin is already in-flight,
   skip. The inner RAF reads scrollHeight at execution time so it
   always targets the latest bottom. User-interrupt paths (wheel,
   touch) still cancel via cancelPendingPins().

2. Add overscroll-behavior:contain to the scroll container. Prevents
   elastic overscroll from generating extra scroll events that could
   flip autoScrollRef to false.
2026-03-30 12:36:42 +00:00
Mathias Fredriksson f2845f6622 feat(site): humanize process_signal and show killed on processes (#23590)
Replace the raw JSON dump for process_signal with the standard
ToolCollapsible + ToolIcon + ToolLabel pipeline, matching process_list
and other generic tools. A thin ProcessSignalRenderer promotes soft
failures (success=false, isError=false) so the generic renderer shows
the error indicator. ToolLabel distinguishes running, success, and
failure states. TerminalIcon used for consistency with other process
tools.

When a process is killed via process_signal, the execute and
process_output blocks show a red OctagonX icon with signal details
on hover. The killedBySignal field is set on MergedTool during the
existing cross-message parsing pass, no new abstractions.

Stories for process_signal (10) and killed indicators (8). Unit
tests for the cross-tool annotation logic (3). Humanized labels
and TerminalIcon for process_list.
2026-03-30 15:35:39 +03:00
Jeremy Ruppel 076e97aa66 feat(site): add client filter to AI Bridge Session table (#23733) 2026-03-30 08:15:45 -04:00
dependabot[bot] 2875053b83 ci: bump the github-actions group with 4 updates (#23789)
Bumps the github-actions group with 4 updates:
[actions/cache](https://github.com/actions/cache),
[fluxcd/flux2](https://github.com/fluxcd/flux2),
[Mattraks/delete-workflow-runs](https://github.com/mattraks/delete-workflow-runs)
and
[umbrelladocs/action-linkspector](https://github.com/umbrelladocs/action-linkspector).

Updates `actions/cache` from 5.0.3 to 5.0.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/releases">actions/cache's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.4</h2>
<h2>What's Changed</h2>
<ul>
<li>Add release instructions and update maintainer docs by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1696">actions/cache#1696</a></li>
<li>Potential fix for code scanning alert no. 52: Workflow does not
contain permissions by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1697">actions/cache#1697</a></li>
<li>Fix workflow permissions and cleanup workflow names / formatting by
<a href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1699">actions/cache#1699</a></li>
<li>docs: Update examples to use the latest version by <a
href="https://github.com/XZTDean"><code>@​XZTDean</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1690">actions/cache#1690</a></li>
<li>Fix proxy integration tests by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1701">actions/cache#1701</a></li>
<li>Fix cache key in examples.md for bun.lock by <a
href="https://github.com/RyPeck"><code>@​RyPeck</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1722">actions/cache#1722</a></li>
<li>Update dependencies &amp; patch security vulnerabilities by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/cache/pull/1738">actions/cache#1738</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/XZTDean"><code>@​XZTDean</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/cache/pull/1690">actions/cache#1690</a></li>
<li><a href="https://github.com/RyPeck"><code>@​RyPeck</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/cache/pull/1722">actions/cache#1722</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.4">https://github.com/actions/cache/compare/v5...v5.0.4</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/blob/main/RELEASES.md">actions/cache's
changelog</a>.</em></p>
<blockquote>
<h1>Releases</h1>
<h2>How to prepare a release</h2>
<blockquote>
<p>[!NOTE]<br />
Relevant for maintainers with write access only.</p>
</blockquote>
<ol>
<li>Switch to a new branch from <code>main</code>.</li>
<li>Run <code>npm test</code> to ensure all tests are passing.</li>
<li>Update the version in <a
href="https://github.com/actions/cache/blob/main/package.json"><code>https://github.com/actions/cache/blob/main/package.json</code></a>.</li>
<li>Run <code>npm run build</code> to update the compiled files.</li>
<li>Update this <a
href="https://github.com/actions/cache/blob/main/RELEASES.md"><code>https://github.com/actions/cache/blob/main/RELEASES.md</code></a>
with the new version and changes in the <code>## Changelog</code>
section.</li>
<li>Run <code>licensed cache</code> to update the license report.</li>
<li>Run <code>licensed status</code> and resolve any warnings by
updating the <a
href="https://github.com/actions/cache/blob/main/.licensed.yml"><code>https://github.com/actions/cache/blob/main/.licensed.yml</code></a>
file with the exceptions.</li>
<li>Commit your changes and push your branch upstream.</li>
<li>Open a pull request against <code>main</code> and get it reviewed
and merged.</li>
<li>Draft a new release <a
href="https://github.com/actions/cache/releases">https://github.com/actions/cache/releases</a>
use the same version number used in <code>package.json</code>
<ol>
<li>Create a new tag with the version number.</li>
<li>Auto generate release notes and update them to match the changes you
made in <code>RELEASES.md</code>.</li>
<li>Toggle the set as the latest release option.</li>
<li>Publish the release.</li>
</ol>
</li>
<li>Navigate to <a
href="https://github.com/actions/cache/actions/workflows/release-new-action-version.yml">https://github.com/actions/cache/actions/workflows/release-new-action-version.yml</a>
<ol>
<li>There should be a workflow run queued with the same version
number.</li>
<li>Approve the run to publish the new version and update the major tags
for this action.</li>
</ol>
</li>
</ol>
<h2>Changelog</h2>
<h3>5.0.4</h3>
<ul>
<li>Bump <code>minimatch</code> to v3.1.5 (fixes ReDoS via globstar
patterns)</li>
<li>Bump <code>undici</code> to v6.24.1 (WebSocket decompression bomb
protection, header validation fixes)</li>
<li>Bump <code>fast-xml-parser</code> to v5.5.6</li>
</ul>
<h3>5.0.3</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<h3>5.0.2</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.3 <a
href="https://redirect.github.com/actions/cache/pull/1692">#1692</a></li>
</ul>
<h3>5.0.1</h3>
<ul>
<li>Update <code>@azure/storage-blob</code> to <code>^12.29.1</code> via
<code>@actions/cache@5.0.1</code> <a
href="https://redirect.github.com/actions/cache/pull/1685">#1685</a></li>
</ul>
<h3>5.0.0</h3>
<blockquote>
<p>[!IMPORTANT]
<code>actions/cache@v5</code> runs on the Node.js 24 runtime and
requires a minimum Actions Runner version of <code>2.327.1</code>.</p>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/cache/commit/668228422ae6a00e4ad889ee87cd7109ec5666a7"><code>6682284</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1738">#1738</a>
from actions/prepare-v5.0.4</li>
<li><a
href="https://github.com/actions/cache/commit/e34039626f957d3e3e50843d15c1b20547fc90e2"><code>e340396</code></a>
Update RELEASES</li>
<li><a
href="https://github.com/actions/cache/commit/8a671105293e81530f1af99863cdf94550aba1a6"><code>8a67110</code></a>
Add licenses</li>
<li><a
href="https://github.com/actions/cache/commit/1865903e1b0cb750dda9bc5c58be03424cc62830"><code>1865903</code></a>
Update dependencies &amp; patch security vulnerabilities</li>
<li><a
href="https://github.com/actions/cache/commit/565629816435f6c0b50676926c9b05c254113c0c"><code>5656298</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1722">#1722</a>
from RyPeck/patch-1</li>
<li><a
href="https://github.com/actions/cache/commit/4e380d19e192ace8e86f23f32ca6fdec98a673c6"><code>4e380d1</code></a>
Fix cache key in examples.md for bun.lock</li>
<li><a
href="https://github.com/actions/cache/commit/b7e8d49f17405cc70c1c120101943203c98d3a4b"><code>b7e8d49</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1701">#1701</a>
from actions/Link-/fix-proxy-integration-tests</li>
<li><a
href="https://github.com/actions/cache/commit/984a21b1cb176a0936f4edafb42be88978f93ef1"><code>984a21b</code></a>
Add traffic sanity check step</li>
<li><a
href="https://github.com/actions/cache/commit/acf2f1f76affe1ef80eee8e56dfddd3b3e5f0fba"><code>acf2f1f</code></a>
Fix resolution</li>
<li><a
href="https://github.com/actions/cache/commit/95a07c51324af6001b4d6ab8dff29f4dfadc2531"><code>95a07c5</code></a>
Add wait for proxy</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/cache/compare/cdf6c1fa76f9f475f3d7449005a359c84ca0f306...668228422ae6a00e4ad889ee87cd7109ec5666a7">compare
view</a></li>
</ul>
</details>
<br />

Updates `fluxcd/flux2` from 2.7.5 to 2.8.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fluxcd/flux2/releases">fluxcd/flux2's
releases</a>.</em></p>
<blockquote>
<h2>v2.8.3</h2>
<h2>Highlights</h2>
<p>Flux v2.8.3 is a patch release that fixes a regression in
helm-controller. Users are encouraged to upgrade for the best
experience.</p>
<p>ℹ️ Please follow the <a
href="https://github.com/fluxcd/flux2/discussions/5572">Upgrade
Procedure for Flux v2.7+</a> for a smooth upgrade from Flux v2.6 to the
latest version.</p>
<p>Fixes:</p>
<ul>
<li>Fix templating errors for charts that include <code>---</code> in
the content, e.g. YAML separators, embedded scripts, CAs inside
ConfigMaps (helm-controller)</li>
</ul>
<h2>Components changelog</h2>
<ul>
<li>helm-controller <a
href="https://github.com/fluxcd/helm-controller/blob/v1.5.3/CHANGELOG.md">v1.5.3</a></li>
</ul>
<h2>CLI changelog</h2>
<ul>
<li>[release/v2.8.x] Add target branch name to update branch by <a
href="https://github.com/fluxcdbot"><code>@​fluxcdbot</code></a> in <a
href="https://redirect.github.com/fluxcd/flux2/pull/5774">fluxcd/flux2#5774</a></li>
<li>Update toolkit components by <a
href="https://github.com/fluxcdbot"><code>@​fluxcdbot</code></a> in <a
href="https://redirect.github.com/fluxcd/flux2/pull/5779">fluxcd/flux2#5779</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fluxcd/flux2/compare/v2.8.2...v2.8.3">https://github.com/fluxcd/flux2/compare/v2.8.2...v2.8.3</a></p>
<h2>v2.8.2</h2>
<h2>Highlights</h2>
<p>Flux v2.8.2 is a patch release that comes with various fixes. Users
are encouraged to upgrade for the best experience.</p>
<p>ℹ️ Please follow the <a
href="https://github.com/fluxcd/flux2/discussions/5572">Upgrade
Procedure for Flux v2.7+</a> for a smooth upgrade from Flux v2.6 to the
latest version.</p>
<p>Fixes:</p>
<ul>
<li>Fix enqueuing new reconciliation requests for events on source Flux
objects when they are already reconciling the revision present in the
watch event (kustomize-controller, helm-controller)</li>
<li>Fix the Go templates bug of YAML separator <code>---</code> getting
concatenated to <code>apiVersion:</code> by updating to Helm 4.1.3
(helm-controller)</li>
<li>Fix canceled HelmReleases getting stuck when they don't have a retry
strategy configured by introducing a new feature gate
<code>DefaultToRetryOnFailure</code> that improves the experience when
the <code>CancelHealthCheckOnNewRevision</code> is enabled
(helm-controller)</li>
<li>Fix the auth scope for Azure Container Registry to use the
ACR-specific scope (source-controller, image-reflector-controller)</li>
<li>Fix potential Denial of Service (DoS) during TLS handshakes
(CVE-2026-27138) by building all controllers with Go 1.26.1</li>
</ul>
<h2>Components changelog</h2>
<ul>
<li>source-controller <a
href="https://github.com/fluxcd/source-controller/blob/v1.8.1/CHANGELOG.md">v1.8.1</a></li>
<li>kustomize-controller <a
href="https://github.com/fluxcd/kustomize-controller/blob/v1.8.2/CHANGELOG.md">v1.8.2</a></li>
<li>notification-controller <a
href="https://github.com/fluxcd/notification-controller/blob/v1.8.2/CHANGELOG.md">v1.8.2</a></li>
<li>helm-controller <a
href="https://github.com/fluxcd/helm-controller/blob/v1.5.2/CHANGELOG.md">v1.5.2</a></li>
<li>image-reflector-controller <a
href="https://github.com/fluxcd/image-reflector-controller/blob/v1.1.1/CHANGELOG.md">v1.1.1</a></li>
<li>image-automation-controller <a
href="https://github.com/fluxcd/image-automation-controller/blob/v1.1.1/CHANGELOG.md">v1.1.1</a></li>
<li>source-watcher <a
href="https://github.com/fluxcd/source-watcher/blob/v2.1.1/CHANGELOG.md">v2.1.1</a></li>
</ul>
<h2>CLI changelog</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/fluxcd/flux2/commit/871be9b40d53627786d3a3835a3ddba1e3234bd2"><code>871be9b</code></a>
Merge pull request <a
href="https://redirect.github.com/fluxcd/flux2/issues/5779">#5779</a>
from fluxcd/update-components-release/v2.8.x</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/f7a168935dd2d777109ea189e0ef094695caeea7"><code>f7a1689</code></a>
Update toolkit components</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/bf67d7799d07eff26891a8b373601f1f07ee4411"><code>bf67d77</code></a>
Merge pull request <a
href="https://redirect.github.com/fluxcd/flux2/issues/5774">#5774</a>
from fluxcd/backport-5773-to-release/v2.8.x</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/5cb2208cb7dda2abc7d4bdc971458981c6be8323"><code>5cb2208</code></a>
Add target branch name to update branch</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/bfa461ed2153ae5e0cca6bce08e0845268fb3088"><code>bfa461e</code></a>
Merge pull request <a
href="https://redirect.github.com/fluxcd/flux2/issues/5771">#5771</a>
from fluxcd/update-pkg-deps/release/v2.8.x</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/f11a921e0cdc6c681a157c7a4777150463eaeec8"><code>f11a921</code></a>
Update fluxcd/pkg dependencies</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/b248efab1d786a27ccddf4b341a1034d67c14b3b"><code>b248efa</code></a>
Merge pull request <a
href="https://redirect.github.com/fluxcd/flux2/issues/5770">#5770</a>
from fluxcd/backport-5769-to-release/v2.8.x</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/4d5e044eb9067a15d1099cb9bc81147b5d4daf37"><code>4d5e044</code></a>
Update toolkit components</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/3c8917ca28a93d6ab4b97379c0c81a4144e9f7d6"><code>3c8917c</code></a>
Merge pull request <a
href="https://redirect.github.com/fluxcd/flux2/issues/5767">#5767</a>
from fluxcd/update-pkg-deps/release/v2.8.x</li>
<li><a
href="https://github.com/fluxcd/flux2/commit/c1f11bcf3d6433dbbb81835eb9f8016c3067d7ef"><code>c1f11bc</code></a>
Update fluxcd/pkg dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/fluxcd/flux2/compare/8454b02a32e48d775b9f563cb51fdcb1787b5b93...871be9b40d53627786d3a3835a3ddba1e3234bd2">compare
view</a></li>
</ul>
</details>
<br />

Updates `Mattraks/delete-workflow-runs` from
5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 to
b3018382ca039b53d238908238bd35d1fb14f8ee
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/mattraks/delete-workflow-runs/compare/5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7...5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7">compare
view</a></li>
</ul>
</details>
<br />

Updates `umbrelladocs/action-linkspector` from 1.4.0 to 1.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/umbrelladocs/action-linkspector/releases">umbrelladocs/action-linkspector's
releases</a>.</em></p>
<blockquote>
<h2>Release v1.4.1</h2>
<p>v1.4.1: PR <a
href="https://redirect.github.com/umbrelladocs/action-linkspector/issues/52">#52</a>
- chore: update actions/checkout to v5 across all workflows</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/UmbrellaDocs/action-linkspector/commit/37c85bcde51b30bf929936502bac6bfb7e8f0a4d"><code>37c85bc</code></a>
Merge pull request <a
href="https://redirect.github.com/umbrelladocs/action-linkspector/issues/52">#52</a>
from UmbrellaDocs/action-v5</li>
<li><a
href="https://github.com/UmbrellaDocs/action-linkspector/commit/badbe56d6b5b23e1b01e0a48b02c8c42c734488c"><code>badbe56</code></a>
chore: update actions/checkout to v5 across all workflows</li>
<li><a
href="https://github.com/UmbrellaDocs/action-linkspector/commit/e0578c9289f053a6b2ab5ff03a1ec3d507bbb790"><code>e0578c9</code></a>
Merge pull request <a
href="https://redirect.github.com/umbrelladocs/action-linkspector/issues/51">#51</a>
from UmbrellaDocs/caching-fix-50</li>
<li><a
href="https://github.com/UmbrellaDocs/action-linkspector/commit/5ede5ac56a1421d000b3c6188c227bee606869ac"><code>5ede5ac</code></a>
feat: enhance reviewdog setup with caching and version management</li>
<li><a
href="https://github.com/UmbrellaDocs/action-linkspector/commit/a73cfa2d0f04a59ec1ab98c0f00fdd36ff5a84a1"><code>a73cfa2</code></a>
Merge pull request <a
href="https://redirect.github.com/umbrelladocs/action-linkspector/issues/49">#49</a>
from Goooler/node24</li>
<li><a
href="https://github.com/UmbrellaDocs/action-linkspector/commit/aee511ae2bf96aa01d6d77ae1c775f2f18909d49"><code>aee511a</code></a>
Update action runtime to node 24</li>
<li>See full diff in <a
href="https://github.com/umbrelladocs/action-linkspector/compare/652f85bc57bb1e7d4327260decc10aa68f7694c3...37c85bcde51b30bf929936502bac6bfb7e8f0a4d">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 12:05:40 +00:00
Jeremy Ruppel 548a648dcb feat(site): add AI session thread page (#23391)
Adds the Session Thread page

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jake Howell <jacob@coder.com>
2026-03-30 08:03:52 -04:00
dependabot[bot] 7d0a49f54b chore: bump google.golang.org/api from 0.272.0 to 0.273.0 (#23782)
Bumps
[google.golang.org/api](https://github.com/googleapis/google-api-go-client)
from 0.272.0 to 0.273.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/google-api-go-client/releases">google.golang.org/api's
releases</a>.</em></p>
<blockquote>
<h2>v0.273.0</h2>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.272.0...v0.273.0">0.273.0</a>
(2026-03-23)</h2>
<h3>Features</h3>
<ul>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3542">#3542</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/a4b47110f2ba5bf8bdb32174f26f609615e0e8dc">a4b4711</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3546">#3546</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0cacfa8557f0f7d21166c4dfef84f60c6d9f1a49">0cacfa8</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md">google.golang.org/api's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.272.0...v0.273.0">0.273.0</a>
(2026-03-23)</h2>
<h3>Features</h3>
<ul>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3542">#3542</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/a4b47110f2ba5bf8bdb32174f26f609615e0e8dc">a4b4711</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3546">#3546</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0cacfa8557f0f7d21166c4dfef84f60c6d9f1a49">0cacfa8</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/2e86962ce58da59e39ffacd1cb9930abe979fd3c"><code>2e86962</code></a>
chore(main): release 0.273.0 (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3545">#3545</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/50ea74c1b06b4bb59546145272bc51fc205b36ed"><code>50ea74c</code></a>
chore(google-api-go-generator): restore aiplatform:v1beta1 (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3549">#3549</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/0cacfa8557f0f7d21166c4dfef84f60c6d9f1a49"><code>0cacfa8</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3546">#3546</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/d38a12991f9cee22a29ada664c5eef3942116ad9"><code>d38a129</code></a>
chore(all): update all (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3548">#3548</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/a4b47110f2ba5bf8bdb32174f26f609615e0e8dc"><code>a4b4711</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3542">#3542</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/67cf706bd3f9bd26f2a61ada3290190c0c8545ff"><code>67cf706</code></a>
chore(all): update module google.golang.org/grpc to v1.79.3 [SECURITY]
(<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3544">#3544</a>)</li>
<li>See full diff in <a
href="https://github.com/googleapis/google-api-go-client/compare/v0.272.0...v0.273.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.272.0&new-version=0.273.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:51:12 +00:00
dependabot[bot] f77d0c1649 chore: bump github.com/hashicorp/go-version from 1.8.0 to 1.9.0 (#23784)
Bumps
[github.com/hashicorp/go-version](https://github.com/hashicorp/go-version)
from 1.8.0 to 1.9.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/hashicorp/go-version/releases">github.com/hashicorp/go-version's
releases</a>.</em></p>
<blockquote>
<h2>v1.9.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements</h3>
<ul>
<li>Add support for prefix of any character by <a
href="https://github.com/brondum"><code>@​brondum</code></a> in <a
href="https://redirect.github.com/hashicorp/go-version/pull/79">hashicorp/go-version#79</a></li>
</ul>
<h3>Internal</h3>
<ul>
<li>Update CHANGELOG for version 1.8.0 enhancements by <a
href="https://github.com/sonamtenzin2"><code>@​sonamtenzin2</code></a>
in <a
href="https://redirect.github.com/hashicorp/go-version/pull/178">hashicorp/go-version#178</a></li>
<li>Bump the github-actions-backward-compatible group across 1 directory
with 2 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/hashicorp/go-version/pull/179">hashicorp/go-version#179</a></li>
<li>Bump the github-actions-breaking group with 4 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/hashicorp/go-version/pull/180">hashicorp/go-version#180</a></li>
<li>Bump the github-actions-backward-compatible group with 3 updates by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/hashicorp/go-version/pull/182">hashicorp/go-version#182</a></li>
<li>Update GitHub Actions to trigger on pull requests and update go
version by <a
href="https://github.com/ssagarverma"><code>@​ssagarverma</code></a> in
<a
href="https://redirect.github.com/hashicorp/go-version/pull/185">hashicorp/go-version#185</a></li>
<li>Bump actions/upload-artifact from 6.0.0 to 7.0.0 in the
github-actions-breaking group across 1 directory by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/hashicorp/go-version/pull/183">hashicorp/go-version#183</a></li>
<li>Bump the github-actions-backward-compatible group across 1 directory
with 2 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/hashicorp/go-version/pull/186">hashicorp/go-version#186</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/sonamtenzin2"><code>@​sonamtenzin2</code></a>
made their first contribution in <a
href="https://redirect.github.com/hashicorp/go-version/pull/178">hashicorp/go-version#178</a></li>
<li><a href="https://github.com/brondum"><code>@​brondum</code></a> made
their first contribution in <a
href="https://redirect.github.com/hashicorp/go-version/pull/79">hashicorp/go-version#79</a></li>
<li><a
href="https://github.com/ssagarverma"><code>@​ssagarverma</code></a>
made their first contribution in <a
href="https://redirect.github.com/hashicorp/go-version/pull/185">hashicorp/go-version#185</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/hashicorp/go-version/compare/v1.8.0...v1.9.0">https://github.com/hashicorp/go-version/compare/v1.8.0...v1.9.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/hashicorp/go-version/blob/main/CHANGELOG.md">github.com/hashicorp/go-version's
changelog</a>.</em></p>
<blockquote>
<h1>1.9.0 (Mar 30, 2026)</h1>
<p>ENHANCEMENTS:</p>
<p>Support parsing versions with custom prefixes via opt-in option in <a
href="https://redirect.github.com/hashicorp/go-version/pull/79">hashicorp/go-version#79</a></p>
<p>INTERNAL:</p>
<ul>
<li>Bump the github-actions-backward-compatible group across 1 directory
with 2 updates in <a
href="https://redirect.github.com/hashicorp/go-version/pull/179">hashicorp/go-version#179</a></li>
<li>Bump the github-actions-breaking group with 4 updates in <a
href="https://redirect.github.com/hashicorp/go-version/pull/180">hashicorp/go-version#180</a></li>
<li>Bump the github-actions-backward-compatible group with 3 updates in
<a
href="https://redirect.github.com/hashicorp/go-version/pull/182">hashicorp/go-version#182</a></li>
<li>Update GitHub Actions to trigger on pull requests and update go
version in <a
href="https://redirect.github.com/hashicorp/go-version/pull/185">hashicorp/go-version#185</a></li>
<li>Bump actions/upload-artifact from 6.0.0 to 7.0.0 in the
github-actions-breaking group across 1 directory in <a
href="https://redirect.github.com/hashicorp/go-version/pull/183">hashicorp/go-version#183</a></li>
<li>Bump the github-actions-backward-compatible group across 1 directory
with 2 updates in <a
href="https://redirect.github.com/hashicorp/go-version/pull/186">hashicorp/go-version#186</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/hashicorp/go-version/commit/b80b1e68c4854757b38663ec02bada2d839b6f56"><code>b80b1e6</code></a>
Update CHANGELOG for version 1.9.0 (<a
href="https://redirect.github.com/hashicorp/go-version/issues/187">#187</a>)</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/e93736f31592c971fe8ebbd600844cad58b18ad8"><code>e93736f</code></a>
Bump the github-actions-backward-compatible group across 1 directory
with 2 u...</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/c009de06b736afce5f36f7180c1356d6a40bee38"><code>c009de0</code></a>
Bump actions/upload-artifact from 6.0.0 to 7.0.0 in the
github-actions-breaki...</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/0474357931d1b2fe3d7ac492bcd8ee4802b3c22c"><code>0474357</code></a>
Update GitHub Actions to trigger on pull requests and update go version
(<a
href="https://redirect.github.com/hashicorp/go-version/issues/185">#185</a>)</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/b4ab5fc7d9d3eb48253b467f8f00b22403ec8089"><code>b4ab5fc</code></a>
Support parsing versions with custom prefixes via opt-in option (<a
href="https://redirect.github.com/hashicorp/go-version/issues/79">#79</a>)</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/25c683be0f3830787e522175e0309e14de37ef7b"><code>25c683b</code></a>
Merge pull request <a
href="https://redirect.github.com/hashicorp/go-version/issues/182">#182</a>
from hashicorp/dependabot/github_actions/github-actio...</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/4f2bcd85ae00b22689501fa029976f6544d18a6b"><code>4f2bcd8</code></a>
Bump the github-actions-backward-compatible group with 3 updates</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/acb8b18f5cb9ada9a3c92a9477e54aab6dd7900f"><code>acb8b18</code></a>
Merge pull request <a
href="https://redirect.github.com/hashicorp/go-version/issues/180">#180</a>
from hashicorp/dependabot/github_actions/github-actio...</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/0394c4f5ebf87c7bdf0a3034ee48613bfe5bf341"><code>0394c4f</code></a>
Merge pull request <a
href="https://redirect.github.com/hashicorp/go-version/issues/179">#179</a>
from hashicorp/dependabot/github_actions/github-actio...</li>
<li><a
href="https://github.com/hashicorp/go-version/commit/b2fbaa797b31cd3b36e55bdc4f20a765acc9a251"><code>b2fbaa7</code></a>
Bump the github-actions-backward-compatible group across 1 directory
with 2 u...</li>
<li>Additional commits viewable in <a
href="https://github.com/hashicorp/go-version/compare/v1.8.0...v1.9.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/hashicorp/go-version&package-manager=go_modules&previous-version=1.8.0&new-version=1.9.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:50:56 +00:00
dependabot[bot] 9f51c44772 chore: bump rust from f7bf1c2 to 1d0000a in /dogfood/coder (#23787)
Bumps rust from `f7bf1c2` to `1d0000a`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rust&package-manager=docker&previous-version=slim&new-version=slim)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:47:12 +00:00
Michael Suchacz 73f6cd8169 feat: suffix-based chat agent selection (#23741)
Adds suffix-based agent selection for chatd. Template authors can direct
chat traffic to a specific root workspace agent by naming it with the
`-coderd-chat` suffix (for example, `coder_agent "dev-coderd-chat"`).
When no suffix match exists, chatd falls back to the first root agent by
`DisplayOrder`, then `Name`. Multiple suffix matches return an error.

The selection logic lives in `coderd/x/chatd/internal/agentselect` and
is shared by chatd core plus the workspace chat tools so all chat entry
points pick the same agent deterministically.

No database migrations, API contract changes, or provider changes. The
experimental sandbox template was split out to #23777.
2026-03-30 11:43:59 +00:00
Danielle Maywood 4c97b63d79 fix(site/src/pages/AgentsPage): toast when git refresh fails due to disconnection (#23779) 2026-03-30 12:35:23 +01:00
Jakub Domeracki 28484536b6 fix(enterprise/aibridgeproxyd): return 403 for blocked private IP CONNECT attempts (#23360)
Previously, when a CONNECT tunnel was blocked because the destination
resolved to
a private/reserved IP range, the proxy returned 502 Bad Gateway —
implying an
upstream failure rather than a deliberate policy block.

Introduce `blockedIPError` as a sentinel type returned by both
`checkBlockedIP`
and `checkBlockedIPAndDial`. `ConnectionErrHandler` now inspects the
error with
`errors.As` and returns 403 Forbidden for policy blocks, keeping 502 for
genuine
dial failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:25:33 +02:00
Danielle Maywood 7a5fd4c790 fix(site): align plus menu icons and add small switch variant (#23769) 2026-03-30 11:18:18 +01:00
Jake Howell 8f73e46c2f feat: automatically generate beta features (#23549)
Closes #15129

Adds a generated **Beta features** table on the feature stages doc,
using the same mainline/stable sparse-checkout approach as the
early-access experiments list.

- Walk `docs/manifest.json` for routes with `state: ["beta"]` and render
a table (title, description, mainline vs stable).
- Inject output between `<!-- BEGIN: available-beta-features -->` /
`END` in `docs/install/releases/feature-stages.md`.
- Rename `scripts/release/docs_update_experiments.sh` →
`docs_update_feature_stages.sh`, refresh the script header, and use
`build/docs/feature-stages` for clone output.

<img width="1624" height="1061" alt="image"
src="https://github.com/user-attachments/assets/5fa811dd-9b80-446b-ae65-ec6e6cfedd6a"
/>
2026-03-30 21:14:52 +11:00
Atif Ali 56171306ff ci: fix SLSA predicate schema in attestation steps (#23768)
Follow-up to #23763.

The custom predicate uses the **SLSA v0.2 schema** (`invocation`,
`configSource`, `metadata`) but declares `predicate-type` as v1.
GitHub's attestation API rejects the mismatch:

```
Error: Failed to persist attestation: Invalid Argument -
predicate is not of type slsa1.ProvenancePredicate
```

This was masked before #23763 because the steps failed earlier on
missing `subject-digest`. Now that digests are provided, this is the
next error.

## Fix

Remove the custom `predicate-type` and `predicate` inputs. Without them,
`actions/attest@v4` auto-generates a correct SLSA v1 predicate from the
GitHub Actions OIDC token — which is what `gh attestation verify`
expects.

- `ci.yaml`: 3 attestation steps (main, latest, version-specific)
- `release.yaml`: 3 attestation steps (base, main, latest)

<details>
<summary>Verification (source code trace of actions/attest@v4)</summary>

1. **`detect.ts`**: No `predicate-type`/`predicate` → returns
`'provenance'` (not `'custom'`)
2. **`main.ts`**: `getPredicateForType('provenance')` →
`generateProvenancePredicate()`
3. **`@actions/toolkit/.../provenance.ts`**:
`buildSLSAProvenancePredicate()` fetches OIDC claims, builds correct v1
predicate with `buildDefinition`/`runDetails`

</details>

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑💻
2026-03-30 15:07:13 +05:00
Danielle Maywood 0b07ce2a97 refactor(site): move AgentChatPageView to correct directory (#23770) 2026-03-30 10:49:21 +01:00
Ethan f2a7fdacfe ci: don't cancel in-progress linear release runs on main (#23766)
The Linear Release workflow had `cancel-in-progress: true`
unconditionally, so a new push to `main` would cancel an already-running
sync. This meant successive PR merges would show you a bunch of red Xs
on CI, even though nothing was wrong.

<img width="958" height="305" alt="image"
src="https://github.com/user-attachments/assets/1bd06948-ef2d-469f-9d48-a82277a6110c"
/>

Other workflows like CI guard against this with `cancel-in-progress: ${{
github.ref != 'refs/heads/main' }}`.

This PR does the same thing to the linear release workflow. The job will
be queued instead.

<img width="678" height="105" alt="image"
src="https://github.com/user-attachments/assets/931e38c8-3de4-40d6-b156-d5de5726d094"
/>

Letting the job finish is not particularly wasteful or anything since
the sync takes 30~ seconds in CI time.
2026-03-30 20:46:21 +11:00
Jaayden Halko 0e78156bcd fix: create scrollable proxy menu list (#23764)
This allows the proxy menu to scroll for very large numbers of proxies
in the menu.

<img width="334" height="802" alt="screenshot"
src="https://github.com/user-attachments/assets/f0e45b9c-5b77-43da-b566-28d0572fd56b"
/>
2026-03-30 09:39:30 +01:00
Atif Ali bc5e4b5d54 ci: fix broken GitHub attestations and update SBOM tooling (#23763)
## Problem

GitHub SLSA provenance attestations have been silently failing on
**every release** since they were introduced. Confirmed across all 10+
release runs checked (v2.29.2 through v2.31.6).

The `actions/attest` action requires `subject-digest` (a `sha256:...`
hash) to identify the artifact being attested, but the workflow only
provided `subject-name` (the image tag like
`ghcr.io/coder/coder:v2.31.6`). This caused every attestation step to
error with:

```
Error: One of subject-path, subject-digest, or subject-checksums must be provided
```

The failures were masked by `continue-on-error: true` and only surfaced
as `##[warning]` annotations that nobody noticed. Enterprise customers
doing `gh attestation verify` would find no provenance records for any
of our Docker images.

> [!NOTE]
> The cosign SBOM attestation (separate step) has been working correctly
the entire time — it uses a different mechanism (`cosign attest --type
spdxjson`) that does not require the same inputs. This fix is
specifically for the GitHub-native SLSA provenance attestations.

## Fix

**Add `subject-digest` to all `actions/attest` steps** (release.yaml +
ci.yaml):
- Base image: capture digest from `depot/build-push-action` output
- Main image: resolve digest via `docker buildx imagetools inspect
--raw` after push
- Latest image: same approach
- Use `subject-name` without tag per the [actions/attest
docs](https://github.com/actions/attest#container-image)

**Update `anchore/sbom-action`** from v0.18.0 to v0.24.0 (node24
support, ahead of the [June 2
deadline](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/)).

All changes remain non-blocking for the release process
(`continue-on-error: true` preserved).

> 🤖 This PR was created with the help of Coder Agents, and is reviewed
by a human.
2026-03-30 13:15:13 +05:00
Ethan 13dfc9a9bb test: harden chatd relay test setup (#23759)
These chatd relay tests were seeding chats through
`subscriber.CreateChat(...)`, which wakes the subscriber and can race
local acquisition against the intended remote-worker setup.

Seed waiting and remote-running chats directly in the database instead,
and point the default OpenAI provider at a local safety-net server so
accidental processing fails locally instead of reaching the live API.

Closes https://github.com/coder/internal/issues/1430
2026-03-30 17:52:01 +11:00
Ethan 54738e9e14 test(coderd/x/chatd): avoid zero-ttl config cache flake (#23762)
This fixes a flaky `TestConfigCache_UserPrompt_ExpiredEntryRefetches` by
making the seeded user prompt entry unambiguously expired before the
cache lookup runs.

The test previously inserted a `tlru` entry with a zero TTL, which
depends on `Set` and `Get` landing in different clock ticks. Switching
that seed entry to a negative TTL keeps the bounded `tlru` cache
behavior while removing the same-tick race.

Close https://github.com/coder/internal/issues/1432
2026-03-30 17:51:51 +11:00
TJ 78986efed8 fix(site): hide table headers during loading and empty states on workspaces page (#23446)
## Problem

When the workspaces table transitions between states (loading →
populated, or populated → empty search results), the table column
headers visibly jump. This happens because the Actions column's width is
content-driven: when workspace rows are present, the action buttons give
it intrinsic width, shrinking the Name/Template/Status columns. When the
body is empty or loading, the Actions column collapses to zero, and the
other columns expand to fill the space.

## Solution

Hide the header content during loading and empty states using
`visibility: hidden` (Tailwind's `invisible` class), which preserves the
row's layout height but hides the text. This prevents the visual jump
since headers aren't visible during the states where column widths
differ.

- **Loading**: first column shows a skeleton bar matching the body
skeleton aesthetic; other columns are invisible
- **Empty search results**: all header content is invisible
- **Populated**: headers display normally

---------

Co-authored-by: Jaayden Halko <jaayden@coder.com>
2026-03-29 23:28:30 -07:00
Kyle Carberry 4d2b0a2f82 feat: persist skills as message parts like AGENTS.md (#23748)
## Summary

Skills are now discovered once on the first turn (or when the workspace
agent changes) and persisted as `skill` message parts alongside
`context-file` parts. On subsequent turns, the skill index is
reconstructed from persisted parts instead of re-dialing the workspace
agent.

This makes skills consistent with the AGENTS.md pattern and is
groundwork for a future `/context` endpoint that surfaces loaded
workspace context to the frontend.

## Changes

- Add `skill` `ChatMessagePartType` with `SkillName` and
`SkillDescription` fields
- Extend `persistInstructionFiles` to also discover and persist skills
as parts
- Add `skillsFromParts()` to reconstruct skill index from persisted
parts on subsequent turns
- Update `runChat()` to use `skillsFromParts` instead of re-dialing
workspace for skills
- Frontend: handle new `skill` part type (skip rendering, hide
metadata-only messages)

## Before / After

| | AGENTS.md | Skills |
|---|---|---|
| **Before** | Persist as `context-file` parts, reconstruct from parts |
In-memory `skillsCache` only, re-dial workspace on cache miss |
| **After** | Persist as `context-file` parts, reconstruct from parts |
Persist as `skill` parts, reconstruct from parts |

The in-memory `skillsCache` remains for `read_skill`/`read_skill_file`
tool calls that need full skill bodies on demand.

<details><summary>Design context</summary>

This is the first step toward a unified workspace context
representation. Currently:
- Context files are persisted as message parts (works)
- Skills were only in-memory (inconsistent)
- Workspace MCP servers are cached in-memory (future work)

Persisting skills as parts means a future `/context` endpoint can query
both context files and skills from the same message parts in the DB,
without depending on ephemeral server-side caches.
</details>
2026-03-29 21:48:17 -04:00
Ethan f7aa46c4ba fix(scaletest/llmmock): emit Anthropic SSE event lines (#23587)
The llmmock Anthropic stream wrote each chunk as `data:` only, so
Anthropic clients never saw the named SSE events they dispatch on and
Claude responses arrived empty even though the stream completed with
HTTP 200.

Update `sendAnthropicStream()` to emit `event: <type>` and `data:
<json>` for each Anthropic chunk while leaving the OpenAI-style streams
unchanged.
2026-03-30 12:21:53 +11:00
dependabot[bot] 4bf46c4435 chore: bump the coder-modules group across 2 directories with 1 update (#23757)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 00:37:51 +00:00
Kyle Carberry be99b3cb74 fix: prioritize context cancellation in WebSocket sendEvent (#23756)
## Problem

Commit 386b449 (PR #23745) changed the `OneWayWebSocketEventSender`
event channel from unbuffered to buffered(64) to reduce chat streaming
latency. This introduced a nondeterministic race in `sendEvent`:

```go
sendEvent := func(event codersdk.ServerSentEvent) error {
    select {
    case eventC <- event:  // buffered channel — almost always ready
    case <-ctx.Done():     // also ready after cancellation
    }
    return nil
}
```

After context cancellation, Go's `select` randomly picks between two
ready cases, so `send()` sometimes returns `nil` instead of `ctx.Err()`.
With the old unbuffered channel the send case was rarely ready (no
reader), masking the bug.

## Fix

Add a priority `select` that checks `ctx.Done()` before attempting the
channel send:

```go
select {
case <-ctx.Done():
    return ctx.Err()
default:
}
select {
case eventC <- event:
case <-ctx.Done():
    return ctx.Err()
}
```

This is the standard Go pattern for prioritizing one channel over
another. When the context is already cancelled, the first select returns
immediately. The second select still handles the case where cancellation
happens concurrently with the send.

## Verification

- Ran the flaky test 20× in a loop (`-count=20`): all passed
- Ran the full `TestOneWayWebSocketEventSender` suite 5× (`-count=5`):
all passed
- Ran the complete `coderd/httpapi` test package: all passed

Fixes coder/internal#1429
2026-03-29 20:11:30 -04:00
Danielle Maywood 588beb0a03 fix(site): restore mobile back button on agent settings pages (#23752) 2026-03-28 23:12:49 +00:00
Michael Suchacz bfeb91d9cd fix: scope title regeneration per chat (#23729)
Previously, generating a new agent title used a page-global pending
state, so one in-flight regeneration disabled the action for every chat
in the Agents UI.

This change tracks regenerations by chat ID, updates the Agents page
contracts to use `regeneratingTitleChatIds`, and adds sidebar story
coverage that proves only the active chat is disabled.
2026-03-29 00:01:53 +01:00
Danielle Maywood a399aa8c0c refactor(site): restructure AgentsPage folder (#23648) 2026-03-28 21:33:42 +00:00
Kyle Carberry 386b449273 perf(coderd): reduce chat streaming latency with event-driven acquisition (#23745)
Previously, when a user sent a message, there was a 0–1000ms (avg
~500ms) polling delay before processing began.
`SendMessage`/`CreateChat`/`EditMessage` set `status='pending'` in the
DB and returned, but nothing woke the processing loop — it was a blind
1-second ticker.

## Changes

**Event-driven acquisition (main change):** Adds a `wakeCh` channel to
the chatd `Server`. `CreateChat`, `SendMessage`, `EditMessage`, and
`PromoteQueued` call `signalWake()` after committing their transactions,
which wakes the run loop to call `processOnce` immediately. The 1-second
ticker remains as a fallback safety net for edge cases (stale recovery,
missed signals).

**Buffer WebSocket write channel:** Changes the
`OneWayWebSocketEventSender` event channel from unbuffered to buffered
(64), decoupling the event producer from WebSocket write speed. The
existing 10s write timeout guards against stuck connections.

<details><summary>Implementation plan & analysis</summary>

The full latency analysis identified these sources of delay in the
streaming pipeline:

1. **Chat acquisition polling** — 0–1000ms (avg 500ms) dead time per
message. Fixed by wake channel.
2. **Unbuffered WebSocket write channel** — each token blocked on the
previous WS write completing. Fixed by buffering.
3. **PersistStep DB transaction per step** — `FOR UPDATE` lock + batch
insert. Not addressed in this PR (medium risk, would overlap DB write
with next provider TTFB).
4. **Multi-hop channel pipeline** — 4 channel hops per token. Not
addressed (medium complexity).

</details>

<details><summary>Test stabilization notes</summary>

`signalWake()` causes the chatd daemon to process chats immediately
after creation/send/edit, which exposed timing assumptions in several
tests that expected chats to remain in `pending` status long enough to
assert on. These tests were updated with `require.Eventually` +
`WaitUntilIdleForTest` patterns to wait for processing to settle before
asserting.

The race detector (`test-go-race-pg`) shows failures in
`TestCreateWorkspaceTool_EndToEnd` and `TestAwaitSubagentCompletion` —
these appear to be pre-existing races in the end-to-end chat flow that
are now exercised more aggressively because processing starts
immediately instead of after a 1s delay. Main branch CI (race detector)
passes without these changes.

</details>
2026-03-28 15:26:42 -04:00
Danielle Maywood 565cf846de fix(site): fix exec tool layout shift on command expand (#23739) 2026-03-28 16:27:54 +00:00
Kyle Carberry a2799560eb fix: use Popover for context indicator on mobile viewports (#23747)
## Problem

The context-usage indicator ring in the agents chat uses a Radix UI
`Tooltip`, which only opens on hover. On mobile/touch devices there is
no hover event, so tapping the indicator does nothing.

## Fix

On mobile viewports (`< 640 px`, matching the existing
`isMobileViewport()` helper), render a `Popover` instead of a `Tooltip`
so that tapping the ring toggles the context-usage info. Desktop
behavior (hover tooltip) is unchanged.

- Extract the trigger button and content into shared variables to avoid
duplication
- Conditionally render `Popover` (mobile) or `Tooltip` (desktop) based
on viewport width
- Both `Popover` and `PopoverContent` were already imported in the file
2026-03-28 12:26:23 -04:00
Michael Suchacz 73bde99495 fix(site): prevent mobile scroll jump during active touch gestures (#23734) 2026-03-28 16:24:12 +01:00
Kyle Carberry a708e9d869 feat: add tool rendering for read_skill, read_skill_file, start_workspace (#23744)
These tools previously fell through to the `GenericToolRenderer` which
showed a wrench icon and the raw tool name. Now they get dedicated icons
and contextual labels.

## Changes

**ToolIcon.tsx** — new icon mappings:
- `read_skill` / `read_skill_file` → `BookOpenIcon`
- `start_workspace` → `PlayIcon`
- `web_search` → `SearchIcon`

**ToolLabel.tsx** — contextual labels:
- `read_skill`: "Reading skill {name}…" → "Read skill {name}"
- `read_skill_file`: "Reading {skill}/{path}…" → "Read {skill}/{path}"
- `start_workspace`: "Starting workspace…" → "Started {name}"
- `web_search`: "Searching \"{query}\"…" → "Searched \"{query}\""

**tool.stories.tsx** — 12 new stories covering running, completed, and
error states for all four tools.

<details>
<summary>Visually verified in Storybook</summary>

All 10 story variants verified:
- ReadSkillRunning / ReadSkillCompleted / ReadSkillError
- ReadSkillFileRunning / ReadSkillFileCompleted / ReadSkillFileError
- StartWorkspaceRunning / StartWorkspaceCompleted / StartWorkspaceError
- WebSearchRunning / WebSearchCompleted / WebSearchNoQuery
</details>
2026-03-28 11:16:11 -04:00
Michael Suchacz 91217a97b9 fix(coderd/x/chatd): guard title generation meta replies (#23708)
Short prompts were producing title-generation meta responses such as "I
am a title generator" and prompt-echo titles. This rewrites the
automatic and manual title prompts to be shorter, less self-referential,
and more focused on returning only the title text.

The change also removes the broader post-generation guard layer, updates
manual regeneration to send real conversation text instead of a meta
instruction, and keeps regression coverage focused on the slimmer prompt
contract.
2026-03-28 15:58:53 +01:00
Danielle Maywood 399080e3bf fix(site/src/pages/AgentsPage): preserve right panel tab state across switches (#23737) 2026-03-28 00:00:17 +00:00
Danielle Maywood 50d9d510c5 refactor(site/src/pages/AgentsPage): clean up manual ref-sync callback patterns (#23732) 2026-03-27 23:09:51 +00:00
Jeremy Ruppel eda1bba969 fix(site): show table loader during session list pagination (#23719)
This patch shows the loading spinner on the AI Bridge Sessions table
during pagination. These API calls can take a noticeable amount of time
on dogfood, and without this it shows stale data while the fetch is
fetching.

Claude with the assist 🤖

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kayla はな <mckayla@hey.com>
2026-03-27 19:09:42 -04:00
Danielle Maywood 808dd64ef6 fix(site): reduce dropdown menu font size on agents page (#23711)
Co-authored-by: Kayla はな <mckayla@hey.com>
2026-03-27 23:09:27 +00:00
Kayla はな 04f7d19645 chore: additional typescript import modernization (#23722) 2026-03-27 16:41:56 -06:00
Jake Howell 71a492a374 feat: implement <ClientFilter /> to AI Bridge request logs (#22694)
Closes #22136

This pull-request implements a `<ClientFilter />` to our `Request Logs`
page for AI Bridge. This will allow the user to select a client which
they wish to filter against. Technically the backend is able to actually
filter against multiple clients at once however the frontend doesn't
currently have a nice way of supporting this (future improvement).

<img width="1447" height="831" alt="image"
src="https://github.com/user-attachments/assets/0be234e2-25f2-4a89-b971-d74817395da1"
/>

---------

Co-authored-by: Jeremy Ruppel <jeremy.ruppel@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:18:28 -04:00
Charlie Voiselle 8c494e2a77 fix(site): sever opener in openAppInNewWindow for slim-window apps (#23117)
Split out from #23000.

## Problem

`openAppInNewWindow()` opens workspace apps in a slim popup without
`noopener`, leaving `window.opener` intact. Since workspace apps can
proxy arbitrary user-hosted content under the dashboard origin, this
exposes the Coder dashboard to tabnabbing and same-origin DOM access.

We cannot simply pass `"noopener"` to `window.open()` because the WHATWG
spec mandates that `window.open()` returns `null` when `"noopener"` is
present — indistinguishable from a blocked popup — which would break the
popup-blocked error toast.

## Solution

Use a two-step approach in `openAppInNewWindow()`:

1. Open `about:blank` first (without `noopener`) so we can detect popup
blockers via the `null` return
2. If the popup succeeded, sever the opener reference with `popup.opener
= null`
3. Navigate the popup to the target URL via `popup.location.href`

This preserves popup-block detection while eliminating the security
exposure.

## Tests

A vitest case in `AppLink.test.tsx` asserts that `open_in="slim-window"`
anchors do not carry `target` or `rel` attributes (since slim-window
opening is handled programmatically via `onClick` / `window.open()`, not
anchor attributes).

---------

Co-authored-by: Kayla はな <kayla@tree.camp>
2026-03-27 15:35:10 -04:00
Kyle Carberry 839165818b feat(coderd/x/chatd): add skills discovery and tools for chatd (#23715)
Adds skill discovery and tools to chatd so the agent can discover and
load `.agents/skills/` from workspaces, following the same pattern as
AGENTS.md instruction loading and MCP tool discovery.

## What changed

### `chattool/skill.go` — discovery, loading, and tools

- **DiscoverSkills** — walks `.agents/skills/` via `conn.LS()` +
`conn.ReadFile()`, parses SKILL.md frontmatter (name + description),
validates kebab-case names match directory names, silently skips
broken/missing entries.
- **FormatSkillIndex** — renders a compact `<available-skills>` XML
block for system prompt injection (~60 tokens for 3 skills). Progressive
disclosure: only names + descriptions in context, full body loaded on
demand.
- **LoadSkillBody** / **LoadSkillFile** — on-demand loading with path
traversal protection and size caps (64KB for SKILL.md, 512KB for
supporting files).
- **read_skill** / **read_skill_file** tools — `fantasy.AgentTool`
implementations following the same pattern as ReadFile and
WorkspaceMCPTool. Receive pre-discovered `[]SkillMeta` via closure to
avoid re-scanning on every call.

### `chatd.go` — integration into runChat

- Skills discovered in the `g2` errgroup parallel with instructions and
MCP tools.
- `skillsCache` (sync.Map) per chat+agent, same invalidation pattern as
MCP tools cache.
- Skill index injected via `InsertSystem` after workspace instructions.
- Re-injected in `ReloadMessages` callback so it survives compaction.
- `read_skill` + `read_skill_file` tools registered when skills are
present (for both root and subagent chats).
- Cache cleaned up in `cleanupStreamIfIdle` alongside MCP tools cache.

## Format compatibility

Uses the same `.agents/skills/<name>/SKILL.md` format as
[coder/mux](https://github.com/coder/mux) and
[openai/codex](https://github.com/openai/codex).
2026-03-27 15:22:13 -04:00
Kyle Carberry 6b77fa74a1 fix: remove bold font-weight from unread chats in agents sidebar (#23725)
Removes the `font-semibold` class applied to unread chat titles in the
`/agents` sidebar. The unread indicator dot already provides sufficient
visual distinction for unread chats.
2026-03-27 13:36:49 -04:00
Atif Ali 25e9fa7120 ci: improve Linear release tracking for scheduled pipeline (#23357)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:26:02 +05:00
Danielle Maywood 60065f6c08 fix(site): replace inline delete confirmations with modals (#23710) 2026-03-27 17:02:18 +00:00
Kyle Carberry bcdc35ee3e feat: add chat read/unread indicator to sidebar (#23129)
## Summary

Adds read/unread tracking for chats so users can see which agent
conversations have new assistant messages they haven't viewed.

## Backend Changes

- Adds `last_read_message_id` column to the `chats` table (migration
000439).
- Computes `has_unread` as a virtual column in `GetChatsByOwnerID` using
an `EXISTS` subquery checking for assistant messages beyond the read
cursor.
- Exposes `has_unread` on the `codersdk.Chat` struct and auto-generated
TypeScript types.
- Updates `last_read_message_id` on stream connect/disconnect in
`streamChat`, avoiding per-message API calls during active streaming.
- Uses `context.WithoutCancel` for the deferred disconnect write so the
DB update succeeds even after the client disconnects.

## Frontend Changes

- Bold title (`font-semibold`) for unread chats in the sidebar.
- Small blue dot indicator next to the relative timestamp.
- Suppresses unread indicator for the currently active chat via
`isActive` from NavLink.

## Design Decisions

- Only `assistant` messages count as unread — the user's own messages
don't trigger the indicator.
- No foreign key on `last_read_message_id` since messages can be deleted
(via rollback/truncation) and the column is just a high-water mark.
- Zero API calls during streaming: exactly 2 DB writes per stream
session (connect + disconnect).
- Unread state refreshes on chat list load and window focus. The
`watchChats` WebSocket optimistically marks non-active chats as unread
on `status_change` events, but does not carry a server-computed
`has_unread` field. Navigating to a chat optimistically clears its
unread indicator in the cache.
2026-03-27 12:15:04 -04:00
Cian Johnston a5c72ba396 fix(coderd/agentapi): trim whitespace from workspace agent metadata values (#23709)
- Trim leading/trailing whitespace from metadata `value` and `error`
fields before storage
- Trimming happens before length validation so whitespace-padded values
are handled correctly
- Add `TrimWhitespace` test covering spaces, tabs, newlines, and
preserved inner whitespace
- No backfill needed (unlogged table, stores only latest value)

> 🤖 Created by a Coder Agent, reviewed by me.
2026-03-27 15:08:47 +00:00
Cian Johnston 3f55b35f68 refactor: replace AsSystemRestricted with narrower actors (#23712)
Replace overly-broad `AsSystemRestricted` with purpose-built actors:

- **OAuth2 provider paths** → `AsSystemOAuth2` (13 call sites across
`tokens.go`, `registration.go`, `apikey.go`)
- **Provisioner daemon health read** → `AsSystemReadProvisionerDaemons`
(1 site in `healthcheck/provisioner.go`)
- **Provisionerd file cache paths** → `AsProvisionerd` (2 sites in
`provisionerdserver.go`, matching existing usage nearby)

<details>
<summary>Implementation notes</summary>

Each replacement actor is a strict subset of `AsSystemRestricted`. Every
DB method
at each call site is already covered by the narrower actor's
permissions:

- `subjectSystemOAuth2`: OAuth2App/Secret/CodeToken (all), ApiKey (Read,
Delete), User (Read), Organization (Read)
- `subjectSystemReadProvisionerDaemons`: ProvisionerDaemon (Read)
- `subjectProvisionerd`: File (Create, Read) plus provisionerd-scoped
resources

No new permissions added. `nolint:gocritic` comments updated to reflect
the new actors.
</details>

> 🤖 Created by a Coder Agent, reviewed by me.
2026-03-27 15:08:30 +00:00
Ehab Younes 97a27d3c09 fix(site/src/pages/AgentsPage): fire chat-ready after store sync so scroll-to-bottom hits rendered DOM (#23693)
`onChatReady` now waits for the store to have all fetched messages (not
just query success), so the DOM has content when the parent frame acts
on the signal. Removed the `scroll-to-bottom` round-trip from
`onChatReady`, the embed page no longer needs the parent to echo a
scroll command back.

Also moved `autoScrollRef` check before the `widthChanged` guard in
`ScrollAnchoredContainer`'s ResizeObserver. The width guard is only
relevant for scroll compensation (user scrolled up); it should never
prevent pinning to bottom when auto-scroll is active. Also tightened the
store sync guard to `storeMessageCount < fetchedMessageCount`.
2026-03-27 18:01:38 +03:00
Kyle Carberry 4ed9094305 perf(site): memoize chat rendering hot path (#23720)
Addresses chat page rendering performance. Profiling with React Profiler
showed `AgentChat` actual render times of 20–31ms (exceeding the
16ms/60fps
budget), with `StickyUserMessage` as the #1 component bottleneck at
35.7%
of self time.

## Changes

**Hoist `createComponents` to module scope** (`response.tsx`):
Previously every `<Response>` instance called `createComponents()` per
render, creating a fresh components map that forced Streamdown to
discard
its cached render tree. Now both light/dark variants are precomputed
once
at module scope.

**Wrap `StickyUserMessage` in `memo()`** (`ConversationTimeline.tsx`):
Profile-confirmed #1 bottleneck. Each instance carries
IntersectionObserver
+ ResizeObserver + scroll handlers; skipping re-render avoids all that
setup.

**Wrap `ConversationTimeline` in `memo()`**
(`ConversationTimeline.tsx`):
Prevents cascade re-renders from the parent when props haven't changed.

**Remove duplicate `buildSubagentTitles`** (`ConversationTimeline.tsx` →
`AgentDetailContent.tsx`): Was computed in both `AgentDetailTimeline`
and
`ConversationTimeline`. Now computed once and passed as a prop.

<details>
<summary>Profiling data & analysis</summary>

### Profiler Metrics
| Metric | Value |
|--------|-------|
| INP (Interaction to Next Paint) | 82ms |
| Processing duration (event handlers) | 52ms |
| AgentChat actual render | 20–31ms (budget: 16ms) |
| AgentChat base render (no memo) | ~100ms |

### Top Bottleneck Components (self-time %)
| Component | Self Time | % |
|-----------|-----------|---|
| StickyUserMessage | 11.0ms | 35.7% |
| ForwardRef (radix-ui) | 7.4ms | 24.0% |
| Presence (radix-ui) | 2.0ms | 6.5% |
| AgentChatInput | 1.4ms | 4.5% |

### Decision log
- Chose module-scope precomputation over `useMemo` for
`createComponents`
  because there are only two possible theme variants and they're static.
- Did not add virtualization — sticky user messages + scroll anchoring
  make it complex. The memoization fixes should be measured first.
- Did not wrap `BlockList` in `memo()` — the React Compiler (enabled for
  `pages/AgentsPage/`) already auto-memoizes JSX elements inside it.
- Phase 2 (verify React Compiler effectiveness on
`parseMessagesWithMergedTools`)
and Phase 3 (radix-ui Tooltip lazy-mounting) deferred to follow-up PRs.

</details>
2026-03-27 14:28:27 +00:00
Kyle Carberry d973a709df feat: add model_intent option to MCP server configs (#23717)
Add a per-MCP-server `model_intent` toggle that wraps tool schemas with
a
`model_intent` field, requiring the LLM to provide a human-readable
description of each tool call's purpose. The intent string is shown as a
status label in the UI instead of opaque tool names, and is
transparently
stripped before the call reaches the remote MCP server.

Built-in tools have rich specialized renderers (terminal blocks, file
diffs,
etc.) and don't need this. MCP tools hit `GenericToolRenderer` which
only
shows raw tool names and JSON — that's where model_intent adds value.

The model learns what to provide via the JSON Schema `description` on
the
`model_intent` property itself — no system prompt changes needed.

<details>
<summary>Implementation details</summary>

### Architecture

Inspired by the `withModelIntent()` pattern from `coder/blink`, adapted
for
Go + React. The wrapping is entirely in the `mcpclient` layer — tool
implementations never see `model_intent`.

**Schema wrapping** (`mcpToolWrapper.Info()`): When enabled, wraps the
original tool parameters under a `properties` key and adds a
`model_intent`
string field with a rich description that teaches the model inline.

**Input unwrapping** (`mcpToolWrapper.Run()`): Strips `model_intent` and
unwraps `properties` before forwarding to the remote MCP server. Handles
three input shapes models may produce:
1. `{ model_intent, properties: {...} }` — correct format
2. `{ model_intent, key: val, ... }` — flat, no wrapper
3. Malformed — falls through gracefully

**Frontend extraction**: `streamState.ts` extracts `model_intent` from
incrementally parsed streaming JSON. `messageParsing.ts` extracts it
from
persisted tool call args.

**UI rendering**: `GenericToolRenderer` shows the capitalized intent
string
as the primary label when available, falling back to the raw tool name.

### Changes
- Database: `model_intent` boolean column on `mcp_server_configs`
- SDK: `ModelIntent` field on config/create/update types
- API: pass-through in create/update handlers + converter
- mcpclient: schema wrapping in `Info()`, input unwrapping in `Run()`
- Frontend: extraction from streaming + persisted args
- UI: intent label in `GenericToolRenderer`, toggle in admin panel
- Tests: 6 new tests (schema wrapping, unwrapping, passthrough,
fallback)

### Decision log
- **Option lives on MCPServerConfig, not model config**: Built-in tools
  already have rich renderers; only MCP tools benefit from model_intent.
- **No system prompt changes**: The JSON Schema `description` on the
  `model_intent` property teaches the model inline.
- **Pointer bool on update request**: Follows existing pattern (`*bool`)
  so PATCH requests don't reset the value when omitted.

</details>
2026-03-27 14:23:25 +00:00
Kyle Carberry 50c0c89503 fix(coderd): refresh expired MCP OAuth2 tokens everywhere (#23713)
Fixes expired MCP OAuth2 tokens causing 401 errors and stale
`auth_connected` status in the UI.

When users authenticate MCP servers (e.g. GitHub) via OAuth2, the access
token and refresh token are stored in the database. However, when the
access token expired, nothing refreshed it anywhere:

- **chatd**: sent the expired token as-is, getting a 401 and skipping
the MCP server
- **list/get endpoints**: reported `auth_connected: true` just because a
token record existed, regardless of expiry

## Changes

### Shared utility: `mcpclient.RefreshOAuth2Token`

Pure function that uses `golang.org/x/oauth2` `TokenSource` to check if
a token is expired (or within 10s of expiry) and refresh it. No DB
dependency — callers handle persistence.

### chatd (`coderd/x/chatd/chatd.go`)

Before calling `mcpclient.ConnectAll`, refreshes expired tokens.
Persists new credentials to the database. Falls back to the old token if
refresh fails.

### List/get MCP server endpoints (`coderd/mcp.go`)

Both `listMCPServerConfigs` and `getMCPServerConfig` now attempt refresh
when checking `auth_connected`. If the token is expired:
- **Has refresh token**: attempt refresh, persist result, report
`auth_connected` based on success
- **No refresh token**: report `auth_connected: false` if expired

This means the UI accurately reflects whether the user's token is
actually usable, rather than just whether a record exists.

<details>
<summary>Design notes</summary>

- `RefreshOAuth2Token` lives in `mcpclient` to avoid circular imports
(`coderd` → `chatd` → `mcpclient` is fine; `chatd` → `coderd` would be
circular).
- DB persistence is handled by each caller with their own authz context
(`AsSystemRestricted` in both cases).
- The `buildAuthHeaders` warning in mcpclient about expired tokens is
kept as defense-in-depth logging.
</details>
2026-03-27 10:06:32 -04:00
Danielle Maywood 0ec0f8faaf fix: guard per-chat cancelQueries to prevent 'Chat not found' race (#23714) 2026-03-27 13:08:58 +00:00
Spike Curtis 9b4d15db9b chore: add Tunneler FSM and partial impl (#23691)
<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->

Adds the Tunneler state machine and logic for handling build updates.   
  
This is a partial implementation and tests. Further PRs will fill out
the other event types.
  
Relates to GRU-18
2026-03-27 08:52:13 -04:00
Matt Vollmer 9e33035631 fix: optimistic pinned reorder and post-drag click suppression (#23704)
## Summary

Fixes two issues with pinned chat drag-to-reorder in the agents sidebar:

1. **Missing optimistic reorder**: After dragging a pinned chat to a new
position, the sidebar flashed back to the old order while waiting for
the server response, then snapped to the new order. Now the react-query
cache is updated optimistically in `onMutate` so the reorder is visually
instant.

2. **Post-drag navigation on upward drag**: Dragging a pinned chat
**upward** caused unintended navigation to that chat on drop. The
browser synthesizes a click from the final `pointerup`, and the previous
suppression listener was attached too low in the DOM tree to reliably
intercept it after items reorder.

## Changes

### Optimistic cache reorder (`site/src/api/queries/chats.ts`)

`reorderPinnedChat.onMutate` now extracts all pinned chats from the
cache, reorders them to match the new position, and writes the updated
`pin_order` values back via `updateInfiniteChatsCache`. The server
response still reconciles on `onSettled`.

### Document-level click suppression (`AgentsSidebar.tsx`)

- Moved click suppression from the pinned container div to
`document.addEventListener('click', handler, true)`. This fires before
any element-level handlers regardless of DOM reordering during drag.
Scoped via `pinnedContainerRef.contains(target)` so it only affects
pinned rows.
- Replaced `recentDragRef` (boolean cleared by `setTimeout(100ms)`) with
`lastDragEndedAtRef` (`performance.now()` timestamp checked against a
300ms window). This eliminates the race where the timeout fires before
the synthetic click arrives.

---

PR generated with Coder Agents
2026-03-27 07:57:23 -04:00
Ethan 83b2f85d63 feat(site/src/pages/AgentsPage): add ArrowUp shortcut to edit last user message (#23705)
Add a keyboard shortcut (ArrowUp on empty input) to start editing the
most recent user message, mirroring Mux's behavior. The shortcut reuses
the existing history-edit flow triggered by the pencil button.

Extract a shared `getEditableUserMessagePayload` helper so the pencil
button and the new shortcut both derive the edit payload identically.
Derive the last editable user message during render in
`AgentDetailInput` from the existing store selectors, keeping the
implementation Effect-free and React Compiler friendly.
2026-03-27 21:43:31 +11:00
Ethan c4ef94aacf fix(coderd/x/chatd): prevent chat hang when workspace agent is unavailable (#23707)
## Problem

Chats with a persisted `agent_id` binding hang indefinitely when the
workspace is stopped. The stale agent row still exists in the DB, so
`ensureWorkspaceAgent` succeeds, but the dial blocks forever in
`AwaitReachable`. The MCP discovery goroutine used an unbounded context,
so `g2.Wait()` never returned and the LLM never started.

## Fix

Three targeted changes restore the pre-binding behavior where stopped
workspaces degrade gracefully instead of blocking:

1. **`dialWithLazyValidation`**: "no agents in latest build" is now a
terminal fast-fail — the hanging dial is canceled and
`errChatHasNoWorkspaceAgent` returned immediately, instead of falling
through to `waitForOriginalDial`.

2. **Pre-LLM workspace setup**: MCP discovery and instruction
persistence gate on `workspaceAgentIDForConn` before attempting any
dial. MCP discovery is bounded by a 5s timeout and checks the in-memory
tool cache first (using the cheap cached agent from
`ensureWorkspaceAgent`), so the common subsequent-turn path has zero DB
queries.

3. **`persistInstructionFiles`**: tracks whether the workspace
connection succeeded and skips sentinel persistence on failure, so the
next turn retries if the workspace is restarted.

## Scenarios

**Running workspace, subsequent turn (hot path):** MCP cache hit via
in-memory cached agent. Zero DB queries, zero dials. Unchanged from
#23274.

**Stopped workspace, persisted binding (the bug):** MCP cache hit (stale
descriptors, fine — they fail at invocation). Pre-LLM setup completes
instantly. Tool invocation enters `dialWithLazyValidation`, dial fails
or hangs, validation discovers no agents, returns
`errChatHasNoWorkspaceAgent`. Model sees the error and can call
`start_workspace`.

**New chat, running workspace:** `ensureWorkspaceAgent` resolves via
latest-build, persists binding. MCP discovery dials and caches tools.

**New chat, stopped workspace:** `ensureWorkspaceAgent` finds no agents,
returns `errChatHasNoWorkspaceAgent`. Pre-LLM setup skips. LLM starts
with built-in tools only.

**Rebuilt workspace (agent switched):** MCP cache hit with stale agent
(harmless for one turn). Tool invocation dials stale agent, fails fast,
`dialWithLazyValidation` switches to new agent, persists updated
binding.

**Workspace restarted after stop:** No sentinel was persisted during the
stopped turn, so instruction persistence retries. Agent binding switches
to the new agent via `workspaceAgentIDForConn`.

**Transient DB error during validation:** Not
`errChatHasNoWorkspaceAgent`, so `dialWithLazyValidation` falls through
to `waitForOriginalDial` (cannot prove stale). No false positive.

**Tool invocation on stopped workspace:** `getWorkspaceConn` calls
`ensureWorkspaceAgent` (returns stale row), then
`dialWithLazyValidation` validation discovers no agents, returns
`errChatHasNoWorkspaceAgent`, cached state cleared, error returned to
model.
2026-03-27 18:47:39 +11:00
Ethan d678c6fb16 fix(coderd/x/chatd): forward local status events to fix delayed-startup banner (#23650)
## Problem

The agent chat delayed-startup banner ("Response startup is taking
longer than expected") could appear even though the model was already
streaming.

The root cause is in `Subscribe()`: `message_part` events were delivered
via the fast local in-process stream, while `status` events were
delivered via PostgreSQL pubsub. Both feed into the same `select`
statement, and Go's `select` picks whichever channel is ready first —
there is no ordering guarantee between channels. So a `message_part`
could outrun the `status=running` that logically precedes it.

The frontend saw content arrive while it still thought the chat was
pending, triggering the banner.

## Fix

Also forward `status` events from the local channel, alongside
`message_part`.

Both event types already travel through the same FIFO subscriber
channel: `publishStatus()` is called before the first `message_part`, so
channel ordering guarantees the frontend sees `status=running` before
any content.

Pubsub still delivers a duplicate `status` event later; the frontend
deduplicates it (`setChatStatus` is idempotent — it early-returns when
the status hasn't changed).
2026-03-27 17:55:19 +11:00
Jaayden Halko 86c3983fc0 feat: add AI Governance seat capacity banners (#23411)
## Summary

Add site-wide banners for AI Governance seat usage thresholds:

1. **90% capacity warning (admin-only):** When actual AI Governance
seats are ≥90% and <100% of the license limit, admins see:
   > "You have used 90% of your AI governance add-on seats."

2. **Over-limit banner (admin-only):** When actual seats exceed the
license limit, admins see a prominent warning:
> "Your organization is using {actual} / {limit} AI Governance user
seats ({X}% over the limit). Contact sales@coder.com"
   - Uses floor whole percentage (Go int division / `Math.floor`)
   - Includes a clickable `mailto:sales@coder.com` link
2026-03-27 05:51:51 +00:00
Michael Suchacz 2312e5c428 feat: add manual chat title regeneration (#23633)
## Summary

Adds a "Generate new title" action that lets users manually regenerate a
chat's title using richer conversation context than the automatic
first-message title path.

## Changes

### Backend
- **New endpoint:** `POST
/api/experimental/chats/{chatID}/title/regenerate` returns the updated
Chat with a regenerated title
- **Manual title algorithm:** Extracts useful user/assistant text turns
→ selects first user turn + last 3 turns → builds context with gap
markers → renders prompt with anti-recency guidance → calls lightweight
model → normalizes output
- **Helpers:** `extractManualTitleTurns`,
`selectManualTitleTurnIndexes`, `buildManualTitleContext`,
`renderManualTitlePrompt`, `generateManualTitle` — all private, with the
public `Server.RegenerateChatTitle` method
- **SDK:** `ExperimentalClient.RegenerateChatTitle(ctx, chatID) (Chat,
error)`
- Persists title via existing `UpdateChatByID` and broadcasts
`ChatEventKindTitleChange`

### Frontend
- API client method + React Query mutation with cache invalidation
- "Generate new title" menu item (with wand icon) in both TopBar and
Sidebar dropdown menus
- Loading/disabled state while regeneration is in-flight
- Error toast on failure
- Stories updated for both menus

### Tests
- `quickgen_test.go`: Table-driven tests for all 4 helper functions
(turn extraction, index selection, context building, prompt rendering)
- `exp_chats_test.go`: Handler tests (ChatNotFound,
NotFoundForDifferentUser, NoDaemon)

## Design notes
- The existing auto-title path (`maybeGenerateChatTitle`, `titleInput`)
is completely unchanged
- Manual regeneration uses richer context (first user turn + last 3
turns + gap markers) vs the auto path's single first message
- Endpoint is experimental and marked with `@x-apidocgen {"skip": true}`
2026-03-27 01:47:19 +01:00
Michael Suchacz f35f2a28e6 refactor(site/src/pages/AgentsPage): normalize transcript scrolling to top-to-bottom flow (#23668)
Converts the Agents page transcript from reverse-scroll
(\`flex-col-reverse\`) to normal top-to-bottom document flow with
explicit auto-scroll pinning.

The previous \`flex-col-reverse\` approach (from #23451) required
browser-specific workarounds for Chrome vs Firefox \`scrollTop\` sign
conventions, and compensation logic that could fight user scroll intent
during streaming. This replaces it with standard scroll math
(\`scrollHeight - scrollTop - clientHeight\`) while keeping the proven
\`ScrollAnchoredContainer\` observer machinery.

Changes:
- \`AgentDetailView.tsx\`: layout flip to \`flex-col\`, standard bottom
detection, explicit prepend restoration via \`pendingPrependRef\`
snapshot, sentinel moved inside content wrapper, initial mount bottom
pin, \`scrollToBottomRef\` imperative API for send/edit, user-interrupt
guard with \`isNearBottom\` check
- \`AgentDetailView.stories.tsx\`: all scroll stories updated for
normal-flow semantics, new \`ScrollAnchorPreservedOnOlderHistoryLoad\`
story with deterministic \`IntersectionObserver\` mock
- \`AgentsSkeletons.tsx\` + \`AgentDetailLoadingView\`: removed
\`flex-col-reverse\` so loading state matches the real transcript layout
- \`AgentDetail.tsx\`: replaced \`scrollTop = 0\` on send/edit with
imperative \`scrollToBottomRef\` call

Supersedes #23576 (which was reverted in #23638). Same behavioral goals
but keeps scroll logic local to \`ScrollAnchoredContainer\` rather than
extracting a separate hook.
2026-03-27 01:31:17 +01:00
Kayla はな 4fab372bdc chore: modernize utils/ imports (#23698) 2026-03-26 16:20:25 -06:00
Kayla はな aa81238cd0 chore: modernize all typescript imports (#23511) 2026-03-26 16:04:24 -06:00
Hugo Dutka f87d6e6e82 feat(site): rich preview for the spawn_computer_use_agent tool (#23684)
Adds preview cards for the `spawn_computer_use_agent` tool. Spawning
that agent now renders a rich saying "Spawning computer use sub-agent".
The "waiting for" tool now displays an inline desktop preview which can
be clicked to reveal the desktop in the sidebar.


https://github.com/user-attachments/assets/e486ca0e-a569-4142-bb12-db3b707967b8
2026-03-26 21:52:03 +00:00
Matt Vollmer 113aaa79a0 feat: add pinned chats with drag-to-reorder (#23615)
https://github.com/user-attachments/assets/bd5d12a1-61b3-4b7d-83b6-317bdfb60b3c

## Summary

Adds pinned chats to the agents page sidebar with server-side
persistence and drag-to-reorder. Users can pin/unpin chats via the
context menu, and pinned chats appear in a dedicated "Pinned" section
above the time-grouped list.

## Database

Migration `000453_chat_pin_order`: adds `pin_order integer DEFAULT 0 NOT
NULL` column on `chats` (0 = unpinned, 1+ = pinned in display order).
Three SQL queries handle pin operations server-side using CTEs with
`ROW_NUMBER()`:

- `PinChatByID`: normalizes existing orders and appends to end
- `UnpinChatByID`: sets target to 0 and compacts remaining pins
- `UpdateChatPinOrder`: shifts neighbors, clamps to `[1, pinned_count]`

All queries exclude archived chats. `ArchiveChatByID` clears `pin_order`
on archive. The handler rejects pinning archived chats with 400.

## Backend

Pin/unpin/reorder go through the existing `PATCH
/api/experimental/chats/{chat}` via the `pin_order` field on
`UpdateChatRequest`. The handler routes based on current pin state:
`pin_order == 0` unpins, `> 0` on an already-pinned chat reorders, `> 0`
on an unpinned chat appends to end.

## Frontend

- `pinChat` / `unpinChat` / `reorderPinnedChat` optimistic mutations
using shared `isChatListQuery` predicate
- Sidebar renders Pinned section above time groups, excludes pinned
chats from time groups
- Pin/Unpin context menu items (hidden for child/delegated chats)
- `@dnd-kit/core` + `@dnd-kit/sortable` for drag-to-reorder with
`MouseSensor`, `TouchSensor`, and `KeyboardSensor`
- Local pin-order override prevents flash on drop; click blocker
prevents NavLink navigation after drag

---
*PR generated with Coder Agents*
2026-03-26 16:52:02 -04:00
Mathias Fredriksson f3a8096ff6 perf(site): move useSpeechRecognition into compiled path (#23689)
The hook lived at src/hooks/, outside the React Compiler scope.
It returned a new object literal every render, causing three
handler guards and two downstream JSX guards in AgentChatInput
(233 cache slots) to always miss.

Move the hook to src/pages/AgentsPage/hooks/ where the compiler
processes it. The compiler auto-memoizes the return object, so
manual useMemo is unnecessary.

Also replace ctorRef.current render-time access with a useState
lazy initializer. The ref access caused a CompileError that
would have prevented compilation. Browser API availability is
constant, so useState captures it once.
2026-03-26 22:34:34 +02:00
Jeremy Ruppel beece6d351 fix(site): make AI Bridge sessions query key more specific (#23687)
Seeing what appears to be a react-query caching issue on dogfood. Not
able to repro on my workspace, so this is sort of a shot in the dark 🤷
2026-03-26 16:13:02 -04:00
Jeremy Ruppel 58f744a5c1 fix(site): remove noisy tooltips from AI Bridge session row (#23690)
The provider and client icons both had a tooltip containing the exact
same content as the badge. This removes the tooltips
2026-03-26 15:58:10 -04:00
Kyle Carberry 0f86c4237e feat: add workspace MCP tool discovery and proxying for chat (#23680)
Coder's chat (chatd) can now discover and use MCP servers configured in
a workspace's `.mcp.json` file. This brings project-specific tooling
(GitHub, databases, docs servers, etc.) into the chat without any manual
configuration.

## How it works

The workspace agent reads `.mcp.json` from the workspace directory (same
format Claude Code uses), connects to the declared MCP servers —
spawning child processes for stdio servers and connecting over the
network for HTTP/SSE — and caches their tool lists. Two new agent HTTP
endpoints expose this:

- `GET /api/v0/mcp/tools` returns the cached tool list (supports
`?refresh=true`)
- `POST /api/v0/mcp/call-tool` proxies calls to the correct server

On each chat turn, chatd calls `ListMCPTools` through the existing
`AgentConn` tailnet connection, wraps each tool as a
`fantasy.AgentTool`, and adds them to the LLM's tool set alongside
built-in and admin-configured MCP tools. Tool names are prefixed with
the server name (`github__create_issue`) to avoid collisions.

Failed server connections are logged and skipped — they never block the
agent or break the chat. Child stdio processes are terminated on agent
shutdown.
2026-03-26 19:57:02 +00:00
Jeremy Ruppel 02b58534a0 fix: use TokenBadges for session list row (#23619)
The sessions list row uses a bespoke token badge instead of the
`<TokenBadge />` component, so this fixes that.
2026-03-26 15:50:32 -04:00
Atif Ali e35fa8b9ee fix(site): use restartWorkspace instead of startWorkspace in schedule dialog (#23658) 2026-03-27 00:45:16 +05:00
Jeremy Ruppel 1358233c83 fix(site): decrease chevron size in AI Bridge session row (#23688)
The Sessions table row chevron icon is too big. Make it smaller 🤏
2026-03-26 15:10:41 -04:00
Asher ea4070c0ce feat: add multi-user dialog select for adding group members (#23396)
Instead of the single-user dropdown we had before.
2026-03-26 10:42:04 -08:00
Hugo Dutka 1b2fab8306 feat(site): enable copy and paste in agents desktop (#23686) 2026-03-26 19:32:48 +01:00
Mathias Fredriksson 94e5de22f7 perf(site): fix compiler memoization gap in AgentDetailInput (#23683)
The React Compiler failed to memoize the messages derivation
chain because a useDashboard() hook call sat between the
messages computation and its consumer (getLatestContextUsage).
An IIFE around the context usage logic also fragmented the
dependency chain.

Replacing the IIFE with a ternary and reordering the non-hook
computation before the hook call lets the compiler group
messages + getLatestContextUsage into a single cache guard
keyed on messagesByID and orderedMessageIDs.
2026-03-26 18:30:06 +00:00
Mathias Fredriksson 6cbb7c6da7 fix(provisioner/terraform): regenerate fixtures with current provider (#23685)
Two test fixtures (devcontainer-resources, multiple-agents-multiple-envs)
were generated before terraform-provider-coder v2.15.0 added the
merge_strategy attribute to coder_env. Running generate.sh with the
current provider adds merge_strategy: "replace" (the default) to all
coder_env resources, causing unstable diffs on every regeneration.
2026-03-26 18:22:45 +00:00
Jeremy Ruppel fc60a6bf9b feat(site): add AIBridge Sessions to deployment menu (#23679)
Adds AI Bridge Sessions link to the deployment menu.
2026-03-26 13:56:51 -04:00
Atif Ali a52153968d fix(site): use Anthropic icon instead of Claude icon for provider (#23661) 2026-03-26 22:25:55 +05:00
Danielle Maywood d18e700699 fix(site): collapse chat toolbar badges fluidly on overflow (#23663) 2026-03-26 17:21:01 +00:00
Mathias Fredriksson 0234e8fffd perf(site): narrow buildStreamTools compiler cache guard dependencies (#23677)
The React Compiler guarded buildStreamTools on the whole
streamState ref, which changes on every text chunk. Refactoring
the function to accept toolCalls and toolResults directly lets
the compiler guard on those sub-fields, which are stable during
text-only streaming.

Before: $[0] !== streamState (misses every text chunk)
After:  $[0] !== toolCalls || $[1] !== toolResults (passes when
        only blocks change)

Verified: 181 functions compile, 0 diagnostics. Reference
stability tests confirm toolCalls/toolResults retain identity
across text-part updates and change when tool data updates.
2026-03-26 17:18:35 +00:00
david-fraley fea4560a64 fix(site): use docs() helper for hardcoded documentation URLs (#23606) 2026-03-26 17:10:47 +00:00
Danielle Maywood 6dee7cf11d perf(site/src/pages/AgentsPage): convert renderBlockList to BlockList component (#23673) 2026-03-26 17:07:36 +00:00
Danielle Maywood d4fc4e0837 fix(site): fix StreamingCodeFence storybook flake (#23681) 2026-03-26 17:00:47 +00:00
david-fraley 8da45c14bc fix(site): fix grammar in batch update description (#23605) 2026-03-26 11:59:46 -05:00
Cian Johnston bfee7e6245 fix: populate all chat fields in pubsub events (#23664)
*Problem:* `publishChatPubsubEvent` was constructing a partial
`codersdk.Chat` that omitted `LastModelConfigID` and other fields. Go's
zero-value UUID caused the sidebar to show "Default model" for chats
received via SSE.

*Solution:*
- Extracted `convertChat`/`convertChats` from `exp_chats.go` into
`db2sdk.Chat`/`db2sdk.Chats`, alongside existing `ChatMessage`,
`ChatQueuedMessage`, and `ChatDiffStatus` converters.
`publishChatPubsubEvent` now calls `db2sdk.Chat(chat, nil)` instead of
maintaining its own copy of the conversion logic
- Added backend integration test
`TestWatchChats/CreatedEventIncludesAllChatFields`
- Added frontend regression tests for nil-UUID and valid model config ID
cases

> 🤖 Created by Coder Agents, reviewed by this human.
2026-03-26 16:49:26 +00:00
Danielle Maywood 52b5d5fdc6 fix(site): match date range picker button height on template insights page (#23667) 2026-03-26 16:36:51 +00:00
Mathias Fredriksson cc4cca90fd perf(site): memo-wrap SmoothedResponse and ReasoningDisclosure to skip completed blocks during streaming (#23674)
During streaming, StreamingOutput's compiler cache guard misses
every chunk because streamState.blocks and streamTools are new
references. This causes renderBlockList to recreate all child JSX
elements, and React calls every child function even for blocks
that finished streaming.

Wrapping SmoothedResponse and ReasoningDisclosure in React.memo
lets React skip the function call entirely when props are stable.
For N completed response blocks and M completed thinking blocks,
this reduces per-chunk function calls from N+M+1 to 1. The
compiler still compiles both inner functions cleanly (6 and 12
cache slots respectively, zero diagnostics).
2026-03-26 16:35:12 +00:00
Hugo Dutka 081d91982a fix(site): fix desktop visibility glitch (#23678)
If the desktop viewer component was hidden, for example after collapsing
the sidebar, the next time it was shown the viewer would be blank. This
PR fixes that.
2026-03-26 17:20:16 +01:00
Danielle Maywood 00cd7b7346 fix: match workspace picker font size to plus menu dropdown (#23670) 2026-03-26 16:12:24 +00:00
Danny Kopping 801e57d430 feat: session detail API (#23203) 2026-03-26 18:09:53 +02:00
Michael Suchacz e937f89081 feat: add enabled toggle to chat model admin panel (#23665)
Adds an `enabled` toggle to the chat model admin create/edit form so
admins
can disable a model without soft-deleting it. Disabled models stay
visible
in admin settings but stop appearing in user-facing model selectors.

The backend already supported this (`chat_model_configs.enabled` column,
filtered queries, and SDK fields). This change wires it into the admin
UI
and adds coverage on both sides.

**Backend:** three new subtests in `coderd/exp_chats_test.go` verifying
the visibility contract (admin sees disabled models, non-admin doesn't,
update-to-disabled preserves the record).

**Frontend:** `enabled` field added to form logic and seeded from the
existing model (defaults to `true` for new models). A Switch+Tooltip
control renders in the form header, matching the MCP Server panel
pattern.
Two interaction stories cover the create-disabled and toggle-existing
flows.
2026-03-26 17:07:20 +01:00
Danielle Maywood 5c7057a67f fix(site): enable streaming-mode Streamdown for live chat output (#23676) 2026-03-26 15:22:54 +00:00
Ehab Younes 249ef7c567 feat(site): harden Agents embed frame communication and add theme sync (#23574)
Add theme synchronization, navigation blocking, scroll-to-bottom
handling, and chat-ready signaling to the agent embed page. The parent
frame can now set light/dark theme via postMessage or query param, and
ThemeProvider skips its own class manipulation when the embed marker is
present. Navigation attempts that leave the embed route are intercepted
and forwarded to the parent frame. The scroll container ref is lifted
to the layout so the parent can request scroll-to-bottom.
2026-03-26 18:03:30 +03:00
Cian Johnston 81fe7543b4 chore: set tls.VersionTLS12 MinVersion in cli/server.go to address gosec warning (#23646)
I was investigating `//nolint` comments and this one popped up.
It raised my eyebrows enough to warrant its own PR.
2026-03-26 14:53:47 +00:00
Kyle Carberry 61d2a4a9b8 fix(site): preserve streaming output when queued message is sent (#23595)
## Problem

When the user sends a message while the agent is actively streaming a
response, `handleSend` called `store.clearStreamState()`
**unconditionally before** the POST request. If the server queues the
message (`response.queued = true` because the agent is busy), the
in-progress stream output is immediately wiped from the UI. The full
text only reappears once the agent finishes and the durable message
arrives via WebSocket — causing a visible cutoff mid-stream.

## Fix

Move `clearStreamState()` from before the POST to **after** the
response, gated behind `!response.queued`:

- **Queued sends** (`response.queued === true`): `clearStreamState()` is
never called. The stream continues uninterrupted. The WebSocket `status`
handler already clears stream state when the chat transitions to
`"pending"` / `"waiting"` after the queued message is dequeued.
- **Non-queued sends** (`response.queued === false`):
`clearStreamState()` + `upsertDurableMessage()` fire immediately after
the POST, same net behavior as before.
- **Edit and promote paths**: Unchanged — those are intentional
interruptions where eager clearing is correct.

### Additional behavior changes (both improvements)

1. **Failed sends no longer wipe stream state.** Previously
`clearStreamState()` ran before the `try` block, so a network error
still wiped the agent's in-progress output. Now the `catch` re-throws
before reaching `clearStreamState()`, preserving the stream on failure.

2. **`clearStreamState()` fires for all non-queued responses**, not just
those with a `message` body. The original guard was `!response.queued &&
response.message`; now `clearStreamState()` is under `!response.queued`
while `upsertDurableMessage` retains the `response.message` check. The
server always sets `message` for non-queued responses, so this is a
no-op in practice but is semantically correct.

## Testing

**AgentDetail.stories.tsx**: New `StreamingSurvivesQueuedSend` story
exercises the full flow — mocks `createChatMessage` to return `{ queued:
true }`, delivers streaming text via WebSocket, sends a message through
the UI, and asserts the streaming text remains visible.
2026-03-26 10:35:31 -04:00
Mathias Fredriksson b23c07cf23 perf(site): use lazy iteration in sliceAtGraphemeBoundary (#23671)
Array.from(graphemeSegmenter.segment(text)) materializes the
entire text into an array before iterating, even though the loop
breaks early at the visible prefix length. During streaming at
60fps, this makes each frame O(full text) instead of O(prefix).

Benchmark on 5000-char text with 200-char prefix: 22.6x faster
(1.44ms to 0.06ms per call, saving 8.3% of the frame budget).
The fallback codepoint path had the same issue with Array.from.
2026-03-26 16:33:48 +02:00
Ethan 87aafd4ae2 fix(site): stabilize date-dependent storybook snapshots (#23657)
_Generated by mux but reviewed by a human_

Several stories computed dates relative to `dayjs()` / `new Date()` at
render time, causing snapshot text to shift daily. I ran into this on my
PRs.

This adds an optional `now` prop to `DateRangePicker`,
`TemplateInsightsControls`, and `CreateTokenForm` so stories can inject
a deterministic clock without global mocking. License stories replace
the misleadingly-named `FIXED_NOW = dayjs().startOf("day")` with
absolute timestamps. All fixed timestamps use noon UTC to avoid timezone
boundary issues.

Affected stories:
- `AgentSettingsPageView`: Usage Date Filter, Usage Date Filter Refetch
Overlay
- `LicenseCard`: Expired/future AI Governance variants, Not Yet Valid
- `LicensesSettingsPage`: Shows Addon Ui For Future License Before Nbf
- `TemplateInsightsControls`: Day
- `CreateTokenPage`: Default
2026-03-27 01:21:52 +11:00
Ethan 4d74603045 fix(coderd/x/chatd): respect provider Retry-After headers in chat retry loop (#23351)
> **PR Stack**
> 1. **#23351** ← `#23282` *(you are here)*
> 2. #23282 ← `#23275`
> 3. #23275 ← `#23349`
> 4. #23349 ← `main`

---

## Summary

`chatretry.Retry()` used pure exponential backoff (1 s, 2 s, 4 s, …) and
never consulted provider `Retry-After` headers. Fantasy's
`ProviderError` carries `ResponseHeaders` including `Retry-After`, but
`chaterror.Classify()` only parsed error text and silently dropped the
structured transport metadata.

This makes `Retry-After` a first-class signal in the classification →
retry pipeline.

<img width="853" height="346" alt="image"
src="https://github.com/user-attachments/assets/65f012b6-8173-43d2-957e-ab9faddea525"
/>


## Changes

### `coderd/chatd/chaterror/classify.go`

- Added `RetryAfter time.Duration` field to `ClassifiedError` — a
normalized minimum retry delay derived from provider response metadata.
- `Classify()` now calls `extractProviderErrorDetails()` before falling
back to text heuristics. Structured `ProviderError.StatusCode` takes
priority over regex extraction.
- `normalizeClassification()` preserves and clamps `RetryAfter`.

### `coderd/chatd/chaterror/provider_error.go` (new)

Provider-specific extraction, isolated from the text-based
classification logic:

- `extractProviderErrorDetails()` unwraps `*fantasy.ProviderError` from
the error chain via `errors.As`.
- `retryAfterFromHeaders()` parses headers in priority order:
  1. `retry-after-ms` (OpenAI-specific, millisecond precision)
  2. `retry-after` (standard HTTP — integer seconds or HTTP-date)
- Case-insensitive header key lookup.

### `coderd/chatd/chatretry/chatretry.go`

- `effectiveDelay(attempt, classified)` computes `max(Delay(attempt),
classified.RetryAfter)` — the provider hint acts as a floor without
weakening the local exponential backoff.
- `Retry()` now uses `effectiveDelay` and passes the effective delay to
both `onRetry(...)` and the sleep timer, so downstream payloads, logs,
and the frontend countdown stay aligned automatically.

### Tests

- `classify_test.go`: Structured provider status + `Retry-After`
extraction, `retry-after-ms` priority, HTTP-date parsing, invalid header
fallback, `WithProvider` preservation.
- `chatretry_test.go`: Retry-after-as-floor semantics — longer hint
wins, shorter hint keeps base delay.

## Design notes

- **No SDK/API/frontend changes needed.** `codersdk.ChatStreamRetry`
already carries `DelayMs` and `RetryingAt`, and the frontend already
consumes them. The fix is purely in the server-side delay computation.
- **Existing retryability rules unchanged.** This fixes *when* we sleep,
not *whether* an error is retryable.
- **Provider hint is a floor:** `max(baseDelay, RetryAfter)` ensures we
never retry earlier than the provider asks, and never weaken our own
backoff curve.
2026-03-27 01:20:46 +11:00
Cian Johnston 847a88c6ca chore: clean up stale and dangerous //nolint comments (#23643)
## Changes

- **Commit 1**: Remove 17 unnecessary `//nolint` directives:
  - `//nolint:varnamelen` — linter not active
  - `//nolint:unused` on exported `SlimUnsupported`
  - `//nolint:govet` in `coderd/httpmw/csrf` — no longer fires
  - `//nolint:revive` on functions refactored since the nolint was added
- `//nolint:paralleltest` citing Go 1.22 loop variable capture
(obsolete)
- Bare `//nolint` narrowed to specific `//nolint:gocritic` with
justification

- **Commit 2**: Fix root causes behind 5 dangerous nolint suppressions:
- Add `MinVersion: tls.VersionTLS12` to TLS client config (removes
`gosec` G402)
- Delete trivial unexported wrappers `apiKey()`/`normalizeProvider()` in
chatprovider (removes `revive` confusing-naming)
- Add doc comments to `StartWithAssert` and `Router` (removes `revive`
exported)
  - Rename unused parameters to `_` in integration test helpers

> 🤖 This PR was created using Coder Agents and reviewed by me.
2026-03-26 14:13:53 +00:00
Jeremy Ruppel a0283ff775 fix(site): use toLocaleString for pagination offsets (#23669)
The Pagination widget localizes the number format of the total results
but not the page offsets.

Before

<img width="620" height="78" alt="Screenshot 2026-03-26 at 09 18 01"
src="https://github.com/user-attachments/assets/7ac0ad9a-7baa-4b30-b3d0-0e0325f8433b"
/>

After

<img width="297" height="42" alt="Screenshot 2026-03-26 at 9 41 22 AM"
src="https://github.com/user-attachments/assets/79c68366-95fa-4012-8419-5cd6f6e10ae3"
/>
2026-03-26 09:50:49 -04:00
Cian Johnston f164463c6a fix(scripts/metricsdocgen): shush the prometheus scanner in CI (#23642)
- Suppress informational `log.Printf` messages from the metrics scanner
when stdout is not a TTY (i.e. piped via `atomic_write` in `make gen` or
CI)
- Genuine warnings (`warnf`) still print unconditionally so real
problems remain visible
- `log.Fatalf` for fatal errors is unchanged

> 🤖 Created by Coder Agents and reviewed by a human
2026-03-26 12:58:02 +00:00
Michael Suchacz 4f063cdc47 feat: separate default and additional Coder Agents system prompts (#23616)
Admins can now control whether the built-in Coder Agents default system
prompt is prepended to their custom instructions, rather than having the
custom prompt silently replace the default.

**Changes:**
- New `include_default_system_prompt` boolean toggle (defaults to `true`
for existing deployments) stored as a site config key — no migration
needed.
- GET `/api/experimental/chats/config/system-prompt` returns the toggle
state, the custom prompt, and a preview of the built-in default.
- PUT persists both the toggle and custom prompt atomically in a single
transaction.
- `resolvedChatSystemPrompt()` composes `[default?, custom?]` joined by
`\n\n`, falling back to the built-in default on DB errors.
- Settings UI adds a Switch toggle with conditional helper text and a
"Preview" button that shows the built-in default prompt via the existing
`TextPreviewDialog`.
- Comprehensive test coverage: 15 subtests covering toggle behavior,
prompt composition matrix, auth boundaries, and integration with chat
creation.
2026-03-26 13:32:41 +01:00
Cian Johnston d175e799da feat: show agent badge on workspace list (#23453)
- Adds `GET /api/experimental/chats/by-workspace` endpoint that returns
workspace_id → latest chat_id mapping
- Modifies FE to fetch this alongside the workspace list, gated on
`agents` experiment and render an "Agent" badge similar to the existing
"Task" badge in `WorkspacesTable`
- Badge links to the "latest chat" linked to the given workspace.

Notes:
- Intentionally uses `fetchWithPostFilter` for RBAC to decouple from
workspaces API — will migrate to `workspaces_expanded` view later.
- If users have multiple chats linked to the same workspace, the badge
will link to the most recently updated one.

> 🤖 This PR was created with the help of Coder Agents, and has been
reviewed by my human. 🧑‍💻
2026-03-26 11:30:12 +00:00
Jaayden Halko 3fb7c6264f feat: display the AI add-on column in the UI on the Users and Organization Members tables (#23291)
## Summary

Adds an entitlement-gated **AI add-on** column to both the **Users**
table and the **Organization Members** table. When
`ai_governance_user_limit` is entitled, each row shows whether the user
is consuming an AI seat.

## Background

The AI governance add-on tracks which users are consuming AI seats.
Admins need visibility into per-user seat consumption directly from the
user management tables. This change surfaces that information through
both the site-wide Users table and the per-organization Members table,
gated behind the `ai_governance_user_limit` entitlement so the column
only appears when the feature is licensed.

## Implementation

### Backend
- **New SQL query** `GetUserAISeatStates`
(`coderd/database/queries/aiseatstate.sql`) — returns user IDs consuming
an AI seat, derived from:
  - Users with entries in `aibridge_interceptions` (AI Bridge usage)
- Users who own workspaces with `has_ai_task = true` builds (AI Tasks
usage)
- **SDK types** — added `has_ai_seat: boolean` to `codersdk.User` and
`codersdk.OrganizationMemberWithUserData`
- **Handler wiring** — both the Users list endpoint (`coderd/users.go`)
and all Members endpoints (`coderd/members.go`) query AI seat state per
page of user IDs and populate the response field
- **dbauthz** — per-user `ActionRead` checks on `ResourceUserObject`

### Frontend
- **Shared `AISeatCell` component**
(`site/src/modules/users/AISeatCell.tsx`) — green `CircleCheck` for
consuming, gray `X` for non-consuming
- **`TableColumnHelpTooltip`** — extended with `ai_addon` variant with
tooltip: *"Users with access to AI features like AI Bridge, Boundary, or
Tasks who are actively consuming a seat."*
- **Column visibility** gated behind
`useFeatureVisibility().ai_governance_user_limit`

## Validation

- Backend: dbauthz full method suite (`TestMethodTestSuite`) passes
including new `GetUserAISeatStates` test
- Backend: `TestGetUsers`, `TestUsersFilter`, CLI golden file tests pass
- Frontend: 7/7 tests pass across `UsersPage.test.tsx` and
`OrganizationMembersPage.test.tsx` (column visibility gating both
directions)
- `go build ./coderd/...` compiles clean
- `pnpm --dir site run lint:types` passes
- `make gen` clean

## Risks

- **Pagination performance**: The AI seat query is scoped to the current
page's user IDs (not a full table scan), keeping it efficient for
paginated views.
- **Semantic scope**: The workspace-side AI seat derivation uses "any
build with `has_ai_task = true`" rather than "latest build only". If the
product intent is latest-build-only, this can be tightened in a
follow-up.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking:
`xhigh` • Cost: `$27.25`_

<!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh
costs=27.25 -->
2026-03-26 10:36:40 +00:00
Danny Kopping 09d2588e2a docs: AI session auditing (#23660)
_Disclaimer: produced with the help of Claude Opus 4.6, heavily modified
by me._

Closes https://github.com/coder/internal/issues/1341

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2026-03-26 09:49:53 +00:00
Danny Kopping 8eade29e68 chore: update AI Bridge warning to require AI Governance Add-On (#23662)
*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6,
reviewed by me.*

Replace the transitional soft warning message:

> AI Bridge is now Generally Available in v2.30. In a future Coder
version, your deployment will require the AI Governance Add-On to
continue using this feature. Please reach out to your account team or
sales@coder.com to learn more.

with the definitive requirement message:

> The AI Governance Add-On is required to use AI Bridge. Please reach
out to your account team or sales@coder.com to learn more.

Updated in:
- `enterprise/coderd/license/license.go`
- `enterprise/coderd/license/license_test.go` (2 occurrences)
2026-03-26 11:10:53 +02:00
Ethan 15f2fa55c6 perf(coderd/x/chatd): add process-wide config cache for hot DB queries (#23272)
## Summary

Adds a process-wide cache for three hot database queries in `chatd` that
were hitting Postgres on **every chat turn** despite returning
rarely-changing configuration data:

| Query | Before (50k turns) | After | Reduction |
|---|---|---|---|
| `GetEnabledChatProviders` | ~98.6k calls | ~500-1000 | ~99% |
| `GetChatModelConfigByID` | ~49.2k calls | ~500-1000 | ~98% |
| `GetUserChatCustomPrompt` | ~46.7k calls | ~1000-2000 | ~97% |

These were identified via `coder exp scaletest chat` (5000 concurrent
chats × 10 turns) as the dominant source of Postgres load during chat
processing.

## Design

Follows the established **webpush subscription cache pattern**
(`coderd/webpush/webpush.go`):
- `sync.RWMutex` + `tailscale.com/util/singleflight` (generic) +
generation-based stale prevention + TTL
- 10s TTL for provider/model config, 5s TTL for user prompts
- Negative caching for `sql.ErrNoRows` on user prompts (the common case
— most users don't set custom prompts)
- Deep-clones `ChatModelConfig.Options` (`json.RawMessage` = `[]byte`)
on both store and read paths

### Invalidation

Single pubsub channel (`chat:config_change`) with kind discriminator for
cross-replica cache invalidation. Seven publish points in
`coderd/chats.go` cover all admin mutation endpoints
(create/update/delete for providers and model configs, put for user
prompts).

_This PR was generated with mux and was reviewed by a human_
2026-03-26 18:04:53 +11:00
Danny Kopping 2ff329b68a feat(site): add banner on request-logs page directing users to sessions (#23629)
*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6*

Adds an info banner on the `/aibridge/request-logs` page encouraging
users to visit `/aibridge/sessions` for an improved audit experience.

This allows us to validate whether customers still find the raw request
logs view useful before removing it in a future release.

Fixes #23563
2026-03-26 11:57:50 +05:00
Ethan ad3d934290 fix(site/src/pages/AgentsPage): clear retry banner on stream forward progress (#23653)
When a provider request fails and retries, the "Retrying request" banner
lingered in the UI after the retry succeeded. This happened because
`retryState` was only cleared on explicit `status` events (`running`,
`pending`, `waiting`), not when the stream resumed with `message_part`
or `message` events. Since the backend does not publish a
dedicated"retry resolved" event, the banner stayed visible for the
entire duration of the successful response.

Add `store.clearRetryState()` calls to the `message_part`, `message`,
and `status` event handlers so the banner disappears as soon as content
flows again.

Closes https://github.com/coder/coder/issues/23624
2026-03-26 17:41:13 +11:00
Ethan 21c2acbad5 fix: refine chat retry status UX (#23651)
Follow-up to #23282. The retry and terminal error callouts had a few UX
oddities:

- Auto-retrying states reused backend error text that said "Please try
again" even while the UI was already retrying on behalf of the user.
- Terminal error states also said "Please try again" with no action the
user could take.
- `startup_timeout` had no specific title or retry copy — it fell
through to the generic "Retrying request" heading.
- The kind pill showed raw enum values like `startup_timeout` and
`rate_limit`.
- Terminal error metadata showed a "Retryable" / "Not retryable" label
that does not help users.
- A separate "Provider anthropic" metadata row duplicated information
already present in the message body.
- The `usage-limit` error kind used a hyphen while every backend kind
uses underscores.

Changes:

**Backend (`chaterror/message.go`)**

- Split message generation into `terminalMessage()` and
`retryMessage()`, replacing the old `userFacingMessage()`.
- Terminal messages include HTTP status codes and actionable guidance
(e.g. "Check the API key, permissions, and billing settings.").
- Retry messages are clean factual statements without status codes or
remediation, suitable for the retry countdown UI (e.g. "Anthropic is
temporarily overloaded.").
- Removed "Please try again" / "Please try again later" from all paths.
- `StreamRetryPayload` calls `retryMessage()` instead of forwarding
`classified.Message`.

**Frontend**

- Removed the parallel frontend message-generation system:
`getRetryMessage()`, `getProviderDisplayName()`,
`getRetryProviderSubject()`, and the `PROVIDER_DISPLAY_NAMES` map are
all deleted from `chatStatusHelpers.ts`.
- `liveStatusModel.ts` passes `retryState.error` through directly — the
backend owns the copy.
- Added specific title and retry copy for `startup_timeout`, and
extended the title mapping to cover `auth` and `config`.
- Kind pills now show humanized labels ("Startup timeout", "Rate limit",
etc.) instead of raw enum strings.
- Removed the redundant "Provider anthropic" metadata row.
- Removed the terminal "Retryable" / "Not retryable" badge.
- Normalized `"usage-limit"` → `"usage_limit"` and added it to
`ChatProviderFailureKind` so all error kinds follow the same underscore
convention and live in one enum.

Refs #23282.
2026-03-26 17:37:27 +11:00
Ethan 411714cd73 fix(dogfood/coder): tolerate stale gh auth state (#23588)
## Problem

The dogfood startup script uses `gh auth status` to decide whether to
re-authenticate the GitHub CLI. That command exits non-zero when **any**
stored credential is invalid—even if Coder external auth already injects
a working `GITHUB_TOKEN` into the environment and `gh` commands work
fine.

On workspaces with a persistent home volume, `~/.config/gh/hosts.yml`
retains OAuth tokens written by previous `gh auth login --with-token`
calls. These tokens are issued by Coder's external auth integration and
can be rotated or revoked between workspace starts, but the copy in
`hosts.yml` persists on the volume. When the stored token goes stale,
`gh auth status` reports two accounts:

```
✓ Logged in to github.com account user (GITHUB_TOKEN)           ← works fine
✗ Failed to log in to github.com account user (hosts.yml)       ← stale token
```

It exits 1 because of the stale entry, even though `gh` API calls
succeed via `GITHUB_TOKEN`. This makes the auth state **indeterminate**
from `gh auth status` alone—you can't tell whether `gh` actually works
or not.

When the script enters the login branch:

1. `gh auth login --with-token` **refuses** to accept piped input when
`GITHUB_TOKEN` is already set in the environment, and exits 1.
2. `set -e` kills the script before it reaches `sudo service docker
start`.

The result: Docker never starts, devcontainer health checks fail, and
the workspace reports a startup error—all because of a stale GitHub CLI
credential that has no bearing on workspace functionality.

## Fix

- Switch the auth guard from `gh auth status` to `gh api user --jq
.login`, which tests whether GitHub API access actually works regardless
of which credential provides it.
- Wrap the fallback `gh auth login` so a failure logs the indeterminate
state but does not abort the script.
2026-03-26 17:25:42 +11:00
Ethan 61e31ec5cc perf(coderd/x/chatd): persist workspace agent binding across chat turns (#23274)
## Summary

This change removes the steady-state "resolve the latest workspace
agent" query from chat execution.

Instead of asking the database for the latest build's agent on every
turn, a chat now persists the workspace/build/agent binding it actually
uses and reuses that binding across subsequent turns. The common path
becomes "load the bound agent by ID and dial it", with fallback paths to
repair the binding when it is missing, stale, or intentionally changed.

## What changes

- add `workspace_id`, `build_id`, and `agent_id` binding fields to
`chats`
- expose those fields through the chat API / SDK so the execution
context is explicit
- load the persisted binding first in chatd, instead of always resolving
the latest build's agent
- persist a refreshed binding when chatd has to re-resolve the workspace
agent
- keep child / subagent chats on the same bound workspace context by
inheriting the parent binding
- leave `build_id` / `agent_id` unset for flows like `create_workspace`,
then bind them lazily on the next agent-backed turn

## Runtime behavior

The binding is treated as an optimistic cache of the agent a chat should
use:

- if the bound agent still exists and dials successfully, we use it
without a latest-build lookup
- if the bound agent is missing or no longer reachable, chatd
re-resolves against the latest build and persists the new binding
- if a workspace mutation changes the chat's target workspace, the
binding is updated as part of that mutation

To avoid reintroducing a hot-path query, dialing uses lazy validation:

- start dialing the cached agent immediately
- only validate against the latest build if the dial is still pending
after a short delay
- if validation finds a different agent, cancel the stale dial, switch
to the current agent, and persist the repaired binding

## Result

The hot path stops issuing
`GetWorkspaceAgentsInLatestBuildByWorkspaceID` for every user message,
which is the source of the DB pressure this PR is addressing. At the
same time, chats still converge to the correct workspace agent when the
binding becomes stale due to rebuilds or explicit workspace changes.
2026-03-26 17:22:38 +11:00
Ethan 17aea0b19c feat(site): make long execute tool commands expandable (#23562)
Previously, long bash commands in the execute tool were truncated with
an ellipsis and could not be viewed in full. The only way to see the
full command was to copy it via the copy button.

Adds overflow detection and an inline expand/collapse chevron next to
the copy button. Clicking the command text or the chevron toggles
between truncated and wrapped views. Short commands that fit on one line
are visually unchanged.



https://github.com/user-attachments/assets/88ec6cd4-5212-4608-9a90-9ce217d5dce7

EDIT: couldn't be bothered re-recording the video but the chevron is
hidden until hovered now, like the copy button.
2026-03-26 15:49:23 +11:00
Ethan 5112ab7da9 fix(site/e2e): fix flaky updateTemplate test expecting transient URL (#23655)
_PR generated by Mux but reviewed by a human_

## Problem

The e2e test `template update with new name redirects on successful
submit` is flaky.

After saving template settings, the app navigates to
`/templates/<name>`, which immediately redirects to
`/templates/<name>/docs` via the router's index route (`<Navigate
to="docs" replace />`). The assertion used `expect.poll()` with
`toHavePathNameEndingWith(`/${name}`)`, which matches only the
**transient intermediate URL** — it only exists while `TemplateLayout`'s
async data fetch is pending. Once the fetch resolves and the `<Outlet
/>` renders, the index route fires the `/docs` redirect and the URL no
longer matches.

## Why it's flaky (not deterministic)

The flakiness depends on whether the template query cache is warm:

- **Cache miss → PASSES**: The mutation's `onSuccess` handler
invalidates the query cache. If `TemplateLayout` needs to re-fetch, it
shows a `<Loader />`, which delays rendering the `<Outlet />` that
contains the `<Navigate to="docs">`. This gives `expect.poll()` time to
see the transient `/new-name` URL → **pass**.
- **Cache hit → FAILS**: If the template data is still in the query
client, `TemplateLayout` renders immediately and the `<Navigate
to="docs" replace />` fires nearly instantly. By the time the first poll
runs, the URL is already `/new-name/docs` → **fail**.

## Fix

Assert the **final stable URL** (`/${name}/docs`) instead of the
transient one.

This is safe because `expect.poll()` is retry-based: it keeps sampling
until a match is found (or timeout). Seeing the transient `/new-name`
URL just causes harmless retries — once the redirect completes and the
URL settles on `/new-name/docs`, the poll matches and the test passes.

| Poll | URL | Ends with `/new-name/docs`? | Action |
|---|---|---|---|
| 1st | `/templates/new-name` | No | Retry |
| 2nd | `/templates/new-name` | No | Retry |
| 3rd | `/templates/new-name/docs` | Yes | **Pass**  |

Closes https://github.com/coder/internal/issues/1403
2026-03-26 04:32:44 +00:00
1297 changed files with 57677 additions and 19070 deletions
+6 -4
View File
@@ -111,8 +111,8 @@ Tier 2 file filters:
- **Modernization Reviewer**: one instance per language present in the diff. Filter by extension:
- Go: `*.go` — reference `.claude/docs/GO.md` before reviewing.
- TypeScript: `*.ts` `*.tsx`
- React: `*.tsx` `*.jsx`
- TypeScript: `*.ts` `*.tsx`: reference `.agents/skills/deep-review/references/typescript.md` before reviewing.
- React: `*.tsx` `*.jsx`: reference `.agents/skills/deep-review/references/react.md` before reviewing.
`.tsx` files match both TypeScript and React filters. Spawn both instances when the diff contains `.tsx` changes — TS covers language-level patterns; React covers component and hooks patterns. Before spawning, verify each instance's filter produces a non-empty diff. Skip instances whose filtered diff is empty.
@@ -155,9 +155,11 @@ File scope: {filter from step 2}.
Output file: {REVIEW_DIR}/{role-name}.md
```
For the Modernization Reviewer (Go), add after the methodology line:
For Modernization Reviewer instances, add the language reference after the methodology line:
> Read `.claude/docs/GO.md` as your Go language reference before reviewing.
- **Go:** `Read .claude/docs/GO.md as your Go language reference before reviewing.`
- **TypeScript:** `Read .agents/skills/deep-review/references/typescript.md as your TypeScript language reference before reviewing.`
- **React:** `Read .agents/skills/deep-review/references/react.md as your React language reference before reviewing.`
For re-reviews, append to both Tier 1 and Tier 2 prompts:
@@ -0,0 +1,305 @@
# Modern React (1819.2) + Compiler 1.0 — Reference
Reference for writing idiomatic React. Covers what changed, what it replaced, and what to reach for. Includes React Compiler patterns — what the compiler handles automatically, what it changes semantically, and how to verify its behavior empirically. Scope: client-side SPA patterns only. Server Components, `use server`, and `use client` directives are framework-specific and omitted. Check the project's React version and compiler config before reaching for newer APIs.
## How modern React thinks differently
**Concurrent rendering** (18): React can now pause, interrupt, and resume renders. This is the foundation everything else builds on. Most existing code "just works," but components that produce side effects during render (mutations, subscriptions, network calls in the render body) are unsafe and will misbehave. Concurrent features are opt-in — they only activate when you use a concurrent API like `startTransition` or `useDeferredValue`.
**Urgent vs. non-urgent updates** (18): The `startTransition` / `useTransition` API introduces a formal split between updates that must feel immediate (typing, clicking) and updates that can be interrupted (filtering a large list, navigating to a new screen). Non-urgent updates yield to urgent ones mid-render. Use this instead of `setTimeout` or manual debounce when you want the UI to stay responsive during expensive re-renders.
**Actions** (19): Async functions passed to `startTransition` are called "Actions." They automatically manage pending state, error handling, and optimistic updates as a unit. The `useActionState` hook and `<form action={fn}>` prop are built on this. The pattern replaces the hand-rolled `isPending/setIsPending` + `try/catch` + `setError` boilerplate that was previously necessary for every data mutation.
**Automatic batching** (18): State updates are now batched everywhere — inside `setTimeout`, `Promise.then`, native event handlers, etc. Previously batching only happened inside React-managed event handlers. If you genuinely need a synchronous flush, use `flushSync`.
**Automatic memoization** (Compiler 1.0): React Compiler is a build-time Babel plugin that automatically inserts memoization into components and hooks. It replaces manual `useMemo`, `useCallback`, and `React.memo` — including conditional memoization and memoization after early returns, which manual APIs cannot express. The compiler only processes components and hooks, not standalone functions. It understands data flow and mutability through its own HIR (High-level Intermediate Representation), so it can memoize more granularly than a human would. Projects adopt it incrementally — typically via path-based Babel overrides or the `"use memo"` directive. Components that violate the Rules of React are silently skipped (no build error), so the automated lint tools that check compiler compatibility matter.
## Replace these patterns
The left column reflects patterns common before React 18/19. Write the right column instead. The "Since" column tells you the minimum React version required.
| Old pattern | Modern replacement | Since |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- |
| `ReactDOM.render(<App />, el)` | `createRoot(el).render(<App />)` | 18 |
| `ReactDOM.hydrate(<App />, el)` | `hydrateRoot(el, <App />)` | 18 |
| `ReactDOM.unmountComponentAtNode(el)` | `root.unmount()` | 18 |
| `ReactDOM.findDOMNode(this)` | DOM ref: `const ref = useRef(); ref.current` | 18 |
| `<Context.Provider value={v}>` | `<Context value={v}>` | 19 |
| `React.forwardRef((props, ref) => ...)` | `function Comp({ ref, ...props }) { ... }` (ref as a regular prop) | 19 |
| String ref `ref="input"` in class components | Callback ref or `createRef()` | 19 |
| `Heading.propTypes = { ... }` | TypeScript / ES6 type annotations | 19 |
| `Component.defaultProps = { ... }` on function components | ES6 default parameters `({ text = 'Hi' })` | 19 |
| Legacy Context: `contextTypes` + `getChildContext` | `React.createContext()` + `contextType` | 19 |
| `import { act } from 'react-dom/test-utils'` | `import { act } from 'react'` | 19 |
| `import ShallowRenderer from 'react-test-renderer/shallow'` | `import ShallowRenderer from 'react-shallow-renderer'` | 19 |
| Manual `isPending` state around async calls | `const [isPending, startTransition] = useTransition()` | 18 |
| Manual optimistic state + revert logic | `useOptimistic(currentValue)` | 19 |
| `useEffect` to subscribe to external stores | `useSyncExternalStore(subscribe, getSnapshot)` | 18 |
| Hand-rolled unique ID (counter, random, index) | `useId()` — SSR-safe, hydration-safe | 18 |
| `useEffect` to inject `<title>` or `<meta>` / `react-helmet` | Render `<title>`, `<meta>`, `<link>` directly in components; React hoists them | 19 |
| `ReactDOM.useFormState(action, initial)` (Canary name) | `useActionState(action, initial)` | 19 |
| `useReducer<React.Reducer<State, Action>>(reducer)` | `useReducer(reducer)` — infers from the reducer function | 19 |
| `<div ref={current => (instance = current)} />` (implicit return) | `<div ref={current => { instance = current }} />` (explicit block body) | 19 |
| `useRef<T>()` with no argument | `useRef<T>(undefined)` or `useRef<T \| null>(null)` — argument is now required | 19 |
| `MutableRefObject<T>` type annotation | `RefObject<T>` — all refs are mutable now; `MutableRefObject` is deprecated | 19 |
| `React.createFactory('button')` | `<button />` JSX | 19 |
| `useMemo(() => expr, [deps])` in compiled components | `const val = expr;` — compiler memoizes automatically | C 1.0 |
| `useCallback(fn, [deps])` in compiled components | `const fn = () => { ... };` — compiler memoizes automatically | C 1.0 |
| `React.memo(Component)` in compiled components | Plain component — compiler skips re-render when props are unchanged | C 1.0 |
| `eslint-plugin-react-compiler` (standalone) | `eslint-plugin-react-hooks@latest` (compiler rules merged into recommended) | C 1.0 |
| `useRef` + `useLayoutEffect` for stable callbacks | `useEffectEvent(fn)` — compiler handles both, but `useEffectEvent` is clearer | 19.2 |
## New capabilities
These enable things that weren't practical before. Reach for them in the described situations.
| What | Since | When to use it |
| -------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useTransition()` / `startTransition()` | 18 | Mark a state update as non-urgent so React can interrupt it to handle clicks or keystrokes. The `isPending` boolean lets you show a loading indicator without blocking the UI. |
| `useDeferredValue(value, initialValue?)` | 18 / 19 | Defer re-rendering a slow subtree: pass the deferred value as a prop, wrap the expensive child in `memo`. Unlike debounce, uses no fixed timeout — renders as soon as the browser is idle. The `initialValue` arg (19) avoids a flash on first render. |
| `useId()` | 18 | Generate a stable, SSR-consistent ID for accessibility attributes (`htmlFor`, `aria-describedby`). Do not use for list keys. |
| `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)` | 18 | Subscribe to external (non-React) state stores safely under concurrent rendering. Preferred over `useEffect`-based subscriptions in libraries. |
| `useActionState(action, initialState)` | 19 | Manage an async mutation: returns `[state, wrappedAction, isPending]`. Handles pending, result, and error state as a unit. Replaces the manual `isPending` + `try/catch` + `setError` pattern. |
| `useOptimistic(currentValue)` | 19 | Show a speculative value while an async Action is in flight. Returns `[optimisticValue, setOptimistic]`. React automatically reverts to `currentValue` when the transition settles. |
| `use(promiseOrContext)` | 19 | Read a promise or Context value inside a component or custom hook. Unlike hooks, `use` can be called conditionally (after early returns). Promises must come from a cache — do not create them during render. |
| `useFormStatus()` (from `react-dom`) | 19 | Read `{ pending, data, method, action }` of the nearest parent `<form>` Action. Works across component boundaries without prop drilling — useful for submit buttons inside design-system components. |
| `useEffectEvent(fn)` | 19.2 | Extract a non-reactive callback from an effect. The function sees the latest props/state without being listed in deps, and is never stale. Replaces the `useRef`-and-mutate-in-layout-effect workaround for stable event-like callbacks. The compiler has built-in knowledge of this hook and correctly prunes its return value from effect dependency arrays. Both `useEffectEvent` and the old ref workaround compile cleanly; `useEffectEvent` is preferred for clarity. |
| `<Activity>` | 19.2 | Hide part of the UI while preserving its state and DOM. React deprioritizes updates to hidden content. Use via framework APIs for route prerendering or tab preservation — not a direct replacement for CSS `visibility`. |
| `captureOwnerStack()` | 19.1 | Dev-only API that returns a string showing which components are responsible for rendering the current component (owner stack, not call stack). Useful for custom error overlays. Returns `null` in production. |
| `<form action={fn}>` | 19 | Pass an async function as a form's `action` prop. React handles submission, pending state, and automatic form reset on success. Works with `useActionState` and `useFormStatus`. |
| Ref cleanup function | 19 | Return a cleanup function from a ref callback: `ref={el => { ...; return () => cleanup(); }}`. React calls it on unmount. Replaces the pattern of checking `el === null` in the callback. |
| `<link rel="stylesheet" precedence="default">` | 19 | Declare a stylesheet next to the component that needs it. React deduplicates and inserts it in the correct order before revealing Suspense content. |
| `preinit`, `preload`, `prefetchDNS`, `preconnect` (from `react-dom`) | 19 | Imperatively hint the browser to load resources early. Call from render or event handlers. React deduplicates hints across the component tree. |
| React Compiler (`babel-plugin-react-compiler`) | C 1.0 | Build-time automatic memoization for components and hooks. Install, add to Babel/Vite pipeline. Projects typically start with path-based overrides to compile a subset of files. |
| `"use memo"` directive | C 1.0 | Opt a single function into compilation when using `compilationMode: 'annotation'`. Place at the start of the function body. Module-level `"use memo"` at the top of a file compiles all functions in that file. |
| `"use no memo"` directive | C 1.0 | Temporary escape hatch — skip compilation for a specific component or hook that causes a runtime regression. Not a permanent solution. Place at the start of the function body. |
| Compiler-powered ESLint rules | C 1.0 | Rules for purity, refs, set-state-in-render, immutability, etc. now ship in `eslint-plugin-react-hooks` recommended preset. Surface Rules-of-React violations even without the compiler installed. Note: some projects use Biome instead — check project lint config. |
## Key APIs
### `useTransition` and `startTransition` (18)
`useTransition` returns `[isPending, startTransition]`. Wrap any state update that is not directly tied to the user's current gesture inside `startTransition`. React will render the old UI while computing the new one, and `isPending` is `true` during that window.
In React 19, `startTransition` can accept an async function (an "Action"). React sets `isPending` to `true` for the entire duration of the async work, not just during the synchronous part.
```tsx
// 18: synchronous transition
const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(input));
// 19: async Action — isPending stays true until the await settles
startTransition(async () => {
const err = await updateName(name);
if (err) setError(err);
});
```
Use `startTransition` (the module-level export) when you cannot use the hook (outside a component, in a router callback, etc.).
### `useDeferredValue` (18 / 19)
Creates a "lagging" copy of a value. Pass it to a memoized, expensive component so that React can render the stale UI while computing the updated one.
```tsx
// 19: initialValue shows '' on first render; avoids loading flash
const deferred = useDeferredValue(searchQuery, "");
return <Results query={deferred} />; // Results wrapped in memo
```
`deferred !== searchQuery` while the deferred render is in progress — use this to show a "stale" indicator.
### `useActionState` (19)
Replaces the `useState` + `isPending` + `try/catch` + `setError` boilerplate for any async operation that can be retried or submitted as a form.
```tsx
const [error, submitAction, isPending] = useActionState(
async (prevState, formData) => {
const err = await updateName(formData.get("name"));
if (err) return err; // returned value becomes next state
redirect("/profile");
return null;
},
null, // initialState
);
// Use submitAction as the form's action prop or call it directly
<form action={submitAction}>
<input name="name" />
<button disabled={isPending}>Save</button>
{error && <p>{error}</p>}
</form>;
```
### `useOptimistic` (19)
Shows a speculative value immediately while an async Action is in progress. React automatically reverts to the server-confirmed value when the Action resolves or rejects.
```tsx
const [optimisticName, setOptimisticName] = useOptimistic(currentName);
const submit = async (formData) => {
const newName = formData.get("name");
setOptimisticName(newName); // shows immediately
await updateName(newName); // reverts if this throws
};
```
### `use()` (19)
Unlike hooks, `use` can appear after conditional statements. Two primary uses:
**Reading a promise** (must be stable — from a cache, not created inline):
```tsx
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // suspends until resolved
return comments.map((c) => <p key={c.id}>{c.text}</p>);
}
```
**Reading context after an early return** (hooks cannot appear after `return`):
```tsx
function Heading({ children }) {
if (!children) return null;
const theme = use(ThemeContext); // valid here; hooks would not be
return <h1 style={{ color: theme.color }}>{children}</h1>;
}
```
### `useSyncExternalStore` (18)
The correct way for libraries (and app code) to subscribe to non-React state. Prevents tearing under concurrent rendering.
```tsx
const value = useSyncExternalStore(
store.subscribe, // called when store changes
store.getSnapshot, // returns current value (must be stable reference if unchanged)
store.getServerSnapshot, // optional: for SSR
);
```
## Verifying compiler behavior
The compiler is a black box unless you inspect its output. When reviewing code in compiled paths, run the compiler on the specific code to see what it actually does. Do not guess — verify.
**Run the compiler on a code snippet:**
```sh
cd site && node -e "
const {transformSync} = require('@babel/core');
const code = \`<paste component here>\`;
const diagnostics = [];
const result = transformSync(code, {
plugins: [
['@babel/plugin-syntax-typescript', {isTSX: true}],
['babel-plugin-react-compiler', {
logger: {
logEvent(_, event) {
if (event.kind === 'CompileError' || event.kind === 'CompileSkip') {
diagnostics.push(event.detail?.toString?.()?.substring(0, 200));
}
},
},
}],
],
filename: 'test.tsx',
});
console.log('Compiled:', result.code.includes('_c('));
if (diagnostics.length) console.log('Diagnostics:', diagnostics);
console.log(result.code);
"
```
**Reading compiled output:**
- `const $ = _c(N)` — allocates N memoization cache slots.
- `if ($[n] !== dep)` — cache invalidation guard. Re-computes when `dep` changes (referential equality).
- `if ($[n] === Symbol.for("react.memo_cache_sentinel"))` — one-time initialization. Runs once on first render, cached forever after. This is how the compiler handles expressions with no reactive dependencies.
- `_temp` functions — pure callbacks the compiler hoisted out of the component body.
**Check all compiled files at once:**
```sh
cd site && pnpm run lint:compiler
```
This runs the compiler on every file in the compiled paths and reports CompileError / CompileSkip diagnostics. Zero diagnostics means all functions compiled cleanly.
**What the compiler catches vs. what it does not:**
The compiler emits `CompileError` for mutations of props, state, or hook arguments during render, and for `ref.current` access during render. The project's lint pipeline catches these automatically — do not flag them in review.
The compiler does **not** flag impure function calls during render (`Math.random()`, `Date.now()`, `new Date()`). Instead it silently memoizes them with a sentinel guard, freezing the value after first render. This changes semantics without any diagnostic. Verify suspicious calls by running the compiler and checking for sentinel guards in the output.
## Pitfalls
Things that are easy to get wrong even when you know the modern API exists. Check your output against these.
**Effects run twice in development with StrictMode.** React 18 intentionally mounts → unmounts → remounts every component in dev to surface effects that are not resilient to remounting. This is not a bug. If an effect breaks on the second mount, it is missing a cleanup function. Write `return () => cleanup()` from every effect that sets up a subscription, timer, or external resource.
**Concurrent rendering can call render multiple times.** The render function (component body) may be called more than once before React commits to the DOM. Side effects (mutations, subscriptions, logging) in the render body will run multiple times. Move them into `useEffect` or event handlers.
**Do not create promises during render and pass them to `use()`.** A new promise is created every render, causing an infinite suspend-retry loop. Create the promise outside the component (module level), or use a caching library (SWR, React Query, `cache()` from React) to stabilize it.
**`useOptimistic` reverts automatically — do not fight it.** The optimistic value is a presentation layer only. When the Action settles, React replaces it with the real `currentValue` you passed in. Do not try to sync optimistic state back to your real state; let React handle the revert.
**`flushSync` opts out of automatic batching.** If third-party code or a browser API (e.g. `ResizeObserver`) calls `setState` and you need synchronous DOM flushing, wrap with `flushSync(() => setState(...))`. This is a last resort; prefer letting React batch.
**`forwardRef` still works in React 19 but will be deprecated.** Function components accept `ref` as a plain prop now. New code should use the prop directly. Existing `forwardRef` wrappers continue to work without changes; migrate when convenient.
**`<Activity>` does not unmount.** Content inside a hidden `<Activity>` boundary stays mounted. Effects keep running. Use it for preserving scroll position or form state, not for preventing expensive mounts — use lazy loading for that.
**TypeScript: implicit returns from ref callbacks are now type errors.** In React 19, returning anything other than a cleanup function (or nothing) from a ref callback is rejected by the TypeScript types. The most common case is arrow-function refs that implicitly return the DOM node:
```tsx
// Error in React 19 types:
<div ref={el => (instance = el)} />
// Fix — use a block body:
<div ref={el => { instance = el; }} />
```
**TypeScript: `useRef` now requires an argument.** `useRef<T>()` with no argument is a type error. Pass `undefined` for mutable refs or `null` for DOM refs you initialize on mount: `useRef<T>(undefined)` / `useRef<HTMLDivElement | null>(null)`.
**`useId` output format changed across versions.** React 18 produced `:r0:`. React 19.1 changed it to `«r0»`. React 19.2 changed it again to `_r0`. Do not parse or depend on the specific format — treat it as an opaque string.
**`useFormStatus` reads the nearest parent `<form>` with a function `action`.** It does not reflect native HTML form submissions — only React Actions. A submit button that is a sibling of `<form>` (rather than a descendant) will not see the form's status.
**Context as a provider (`<Context>`) requires React 19; `<Context.Provider>` still works.** Do not use `<Context>` shorthand in a codebase that needs to support React 18. The two forms can coexist during migration.
**Compiler freezes impure expressions silently.** `Math.random()`, `Date.now()`, `new Date()`, and `window.innerWidth` in a component body all compile without diagnostics. The compiler wraps them in a sentinel guard (`Symbol.for("react.memo_cache_sentinel")`) that runs the expression once and caches the result forever. The value never updates on re-render. Fix: move to a `useState` initializer (`useState(() => Math.random())`), `useEffect`, or event handler.
**Component granularity affects compiler optimization.** When one pattern in a component causes a `CompileError` (e.g., a necessary `ref.current` read during render), the compiler skips the **entire** component. If the rest of the component would benefit from compilation, extract the non-compilable pattern into a small child component. This keeps the parent compiled.
**The compiler only memoizes components and hooks.** Standalone utility functions (even expensive ones called during render) are not compiled. If a utility function is truly expensive, it still needs its own caching strategy outside of React (e.g., a module-level cache, `WeakMap`, etc.).
**Changing memoization can shift `useEffect` firing.** A value that was unstable before compilation may become stable after, causing an effect that depended on it to fire less often. Conversely, future compiler changes may alter memoization granularity. Effects that use memoized values as dependencies should be resilient to these changes — they should be true synchronization effects, not "run this when X changes" hacks.
## Behavioral changes that affect code
- **Automatic batching** (18): State updates in `setTimeout`, `Promise.then`, `addEventListener` callbacks, etc. are now batched into a single re-render. Previously only React synthetic event handlers were batched. Code that relied on unbatched updates (reading DOM synchronously after each `setState`) must use `flushSync`.
- **StrictMode double-invoke** (18): In development, every component is mounted → unmounted → remounted with the previous state. Every effect runs cleanup → setup twice on initial mount. `useMemo` and `useCallback` also double-invoke their functions. Production behavior is unchanged. If a test or component breaks under this, the component had a latent cleanup bug.
- **StrictMode ref double-invoke** (19): In development, ref callbacks are also invoked twice on mount (attach → detach → attach). Return a cleanup function from the ref callback to handle detach correctly.
- **StrictMode memoization reuse** (19): During the second pass of double-rendering, `useMemo` and `useCallback` now reuse the cached result from the first pass instead of calling the function again. Components that are already StrictMode-compatible should not notice a difference.
- **Suspense fallback commits immediately** (19): When a component suspends, React now commits the nearest `<Suspense>` fallback without waiting for sibling trees to finish rendering. After the fallback is shown, React "pre-warms" suspended siblings in the background. This makes fallbacks appear faster but changes the order of rendering work.
- **Error re-throwing removed** (19): Errors that are not caught by an Error Boundary are now reported to `window.reportError` (not re-thrown). Errors caught by an Error Boundary go to `console.error` once. If your production monitoring relied on the re-thrown error, add handlers to `createRoot`: `createRoot(el, { onUncaughtError, onCaughtError })`.
- **Transitions in `popstate` are synchronous** (19): Browser back/forward navigation triggers synchronous transition flushing. This ensures the URL and UI update together atomically during history navigation.
- **`useEffect` from discrete events flushes synchronously** (18): Effects triggered by a click or keydown (discrete events) are now flushed synchronously before the browser paints, consistent with `useLayoutEffect` for those cases.
- **Hydration mismatches treated as errors** (18 / improved in 19): Text content mismatches between server HTML and client render revert to client rendering up to the nearest `<Suspense>` boundary. React 19 logs a single diff instead of multiple warnings, making mismatches much easier to diagnose.
- **New JSX transform required** (19): The automatic JSX runtime introduced in 2020 (`react/jsx-runtime`) is now mandatory. The classic transform (which required `import React from 'react'` in every file) is no longer supported. Most toolchains have already shipped the new transform; check your Babel or TypeScript config if you see warnings.
- **UMD builds removed** (19): React no longer ships UMD bundles. Load via npm and a bundler, or use an ESM CDN (`import React from "https://esm.sh/react@19"`).
- **React Compiler automatic memoization** (Compiler 1.0): Build-time Babel plugin that inserts memoization into components and hooks. Components that follow the Rules of React are automatically memoized; components that violate them are silently skipped (no build error, no runtime change). The compiler can memoize conditionally and after early returns — things impossible with manual `useMemo`/`useCallback`. Works with React 17+ via `react-compiler-runtime`; best with React 19+. Projects adopt incrementally via path-based Babel overrides, `compilationMode: 'annotation'`, or the `"use memo"` / `"use no memo"` directives. Check the project's Vite/Babel config to know which paths are compiled. Compiled components show a "Memo ✨" badge in React DevTools.
@@ -0,0 +1,199 @@
# Modern TypeScript (5.06.0 RC) — Reference
Reference for writing idiomatic TypeScript. Covers what changed, what it replaced, and what to reach for. Respect the project's minimum TypeScript version: don't emit features from a version newer than what the project targets. Check `package.json` and `tsconfig.json` before writing code.
## How modern TypeScript thinks differently
The 5.x era resolves years of module system ambiguity and cleans house on legacy options. Three themes dominate:
**Module semantics are explicit.** `--verbatimModuleSyntax` (5.0) makes import/export intent visible in source: type imports must carry `type`, value imports stay. Combined with `--module preserve` or `--moduleResolution bundler`, the compiler now accurately models what bundlers and modern runtimes actually do. `import defer` (5.9) extends the model to deferred evaluation.
**Resource lifetimes are first-class.** `using` and `await using` (5.2) provide deterministic cleanup without `try/finally`. Any object implementing `Symbol.dispose` participates. `DisposableStack` handles ad-hoc multi-resource cleanup in functions where creating a full class is overkill.
**Inference is smarter about what it knows.** Inferred type predicates (5.5) let `.filter(x => x !== undefined)` produce `T[]` instead of `(T | undefined)[]` automatically. `NoInfer<T>` (5.4) gives library authors precise control over which parameters drive inference. Narrowing now survives closures after last assignment, constant indexed accesses, and `switch (true)` patterns.
**TypeScript 6.0 is a transition release toward 7.0** (the Go-native port). It turns years of soft deprecations into errors and changes several defaults. Most impactful: `types` defaults to `[]` (must list `@types` packages explicitly), `rootDir` defaults to `.`, `strict` defaults to `true`, `module` defaults to `esnext`. Projects relying on implicit behavior need explicit config. Check the deprecations section before upgrading.
## Replace these patterns
The left column reflects patterns still common before TypeScript 5.x. Write the right column instead. The "Since" column tells you the minimum TypeScript version required.
| Old pattern | Modern replacement | Since |
| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------ |
| `--experimentalDecorators` + legacy decorator signatures | Standard decorators (TC39): `function dec(target, context: ClassMethodDecoratorContext)` — no flag needed | 5.0 |
| Requiring callers to add `as const` at call sites | `<const T extends HasNames>(arg: T)``const` modifier on type parameter | 5.0 |
| `--importsNotUsedAsValues` + `--preserveValueImports` | `--verbatimModuleSyntax` | 5.0 |
| `import { Foo } from "..."` when `Foo` is only used as a type | `import { type Foo } from "..."` or `import type { Foo } from "..."` | 5.0 |
| `"extends": "@tsconfig/strictest/tsconfig.json"` chain | `"extends": ["@tsconfig/strictest/tsconfig.json", "./tsconfig.base.json"]` (array form) | 5.0 |
| `try { ... } finally { resource.close(); resource.delete(); }` | `using resource = acquireResource()` — calls `[Symbol.dispose]()` automatically | 5.2 |
| `try { ... } finally { await resource.close() }` | `await using resource = acquireAsyncResource()` | 5.2 |
| Ad-hoc cleanup with multiple `try/finally` blocks | `using cleanup = new DisposableStack(); cleanup.defer(() => ...)` | 5.2 |
| `import data from "./data.json" assert { type: "json" }` | `import data from "./data.json" with { type: "json" }` | 5.3 |
| `.filter(Boolean)` or `.filter(x => !!x)` to remove nulls | `.filter(x => x !== undefined)` or `.filter(x => x !== null)` (infers type predicate) | 5.5 |
| Extra phantom type param to block inference bleed: `<C extends string, D extends C>` | `NoInfer<C>` on the parameter you don't want to drive inference | 5.4 |
| `/** @typedef {import("./types").Foo} Foo */` in JS files | `/** @import { Foo } from "./types" */` (JSDoc `@import` tag) | 5.5 |
| `myArray.reverse()` mutating in place | `myArray.toReversed()` (returns new array) | 5.2 |
| `myArray.sort(cmp)` mutating in place | `myArray.toSorted(cmp)` (returns new array) | 5.2 |
| `const copy = [...arr]; copy[i] = v` | `arr.with(i, v)` (returns new array) | 5.2 |
| Manual `has`/`get`/`set` pattern on `Map` | `map.getOrInsert(key, defaultValue)` or `getOrInsertComputed(key, fn)` | 6.0 RC |
| `new RegExp(str.replace(/[.\*+?^${}() | [\]\\]/g, '\\$&'))` | `new RegExp(RegExp.escape(str))` | 6.0 RC |
| `--moduleResolution node` (node10) | `--moduleResolution nodenext` (Node.js) or `--moduleResolution bundler` (bundlers/Bun) | 6.0 RC |
| `"baseUrl": "./src"` + `"@app/*": ["app/*"]` in paths | Remove `baseUrl`; use `"@app/*": ["./src/app/*"]` in paths directly | 6.0 RC |
| `module Foo { export const x = 1; }` | `namespace Foo { export const x = 1; }` | 6.0 RC |
| `export * from "..."` when all re-exported members are types | `export type * from "..."` (or `export type * as ns from "..."`) | 5.0 |
| `function f(): undefined { return undefined; }` — explicit return required in `: undefined`-returning function | Remove the `return` entirely; `undefined`-returning functions no longer require any return statement | 5.1 |
| Manual type predicate annotation on a simple arrow: `(x: T \| undefined): x is T => x !== undefined` | Remove the annotation; TypeScript infers `x is T` from `!== null/undefined` and `instanceof` checks automatically | 5.5 |
| `const val = obj[key]; if (typeof val === "string") { use(val); }` — extract to const to narrow indexed access | `if (typeof obj[key] === "string") { obj[key].toUpperCase(); }` directly — both `obj` and `key` must be effectively constant | 5.5 |
| Copy narrowed `let`/param to a `const`, or restructure code to escape stale closure narrowing after reassignment | Remove the copy; narrowing survives into closures created after the last assignment to the variable | 5.4 |
| `(arr as string[]).filter(...)` or restructure to avoid "not callable" errors on `string[] \| number[]` | Call `.filter`, `.find`, `.some`, `.every`, `.reduce` directly on union-of-array types | 5.2 |
| `if`/`else` chain used to work around lack of narrowing inside a `switch (true)` body | `switch (true)` — each `case` condition now narrows the tested variable in its clause | 5.3 |
## New capabilities
These enable things that weren't practical before. Reach for them in the described situations.
| What | Since | When to use it |
| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `using` / `await using` declarations | 5.2 | Any resource needing deterministic cleanup (file handles, DB connections, locks, event listeners). Object must implement `Symbol.dispose` / `Symbol.asyncDispose`. |
| `DisposableStack` / `AsyncDisposableStack` | 5.2 | Ad-hoc multi-resource cleanup without creating a class. Call `.defer(fn)` right after acquiring each resource. Stack disposes in LIFO order. |
| `const` modifier on type parameters | 5.0 | Force `const`-like (literal/readonly tuple) inference at call sites without requiring callers to write `as const`. Constraint must use `readonly` arrays. |
| Decorator metadata (`Symbol.metadata`) | 5.2 | Attach and read per-class metadata from decorators via `context.metadata`. Retrieved as `MyClass[Symbol.metadata]`. Requires `Symbol.metadata ??= Symbol(...)` polyfill. |
| `NoInfer<T>` utility type | 5.4 | Prevent a parameter from contributing inference candidates for `T`. Use when one argument should be the "source of truth" and others should only be checked against it. |
| Inferred type predicates | 5.5 | Filter callbacks that test for `!== null` or `instanceof` now automatically produce a type predicate. `Array.prototype.filter` then narrows the result array type. |
| `--isolatedDeclarations` | 5.5 | Require explicit return types on exported declarations. Unlocks parallel declaration emit by external tooling (esbuild, oxc, etc.) without needing a full type-checker pass. |
| `${configDir}` in tsconfig paths | 5.5 | Anchor `typeRoots`, `paths`, `outDir`, etc. in a shared base tsconfig to the _consuming_ project's directory, not the shared file's location. |
| Always-truthy/nullish check errors | 5.6 | Catches regex literals in `if`, arrow functions as comparators, `?? 100` on non-nullable left side, misplaced parentheses. No API to call; existing bugs now surface as errors. |
| Iterator helper methods (`IteratorObject`) | 5.6 | Built-in iterators from `Map`, `Set`, generators, etc. now have `.map()`, `.filter()`, `.take()`, `.drop()`, `.flatMap()`, `.toArray()`, `.reduce()`, etc. Use `Iterator.from(iterable)` to wrap any iterable. |
| `--noUncheckedSideEffectImports` | 5.6 | Error when a side-effect import (`import "..."`) resolves to nothing. Catches typos in polyfill or CSS imports. |
| `--noCheck` | 5.6 | Skip type checking entirely during emit. Useful for separating "fast emit" from "thorough check" pipeline stages, especially with `--isolatedDeclarations`. |
| `--rewriteRelativeImportExtensions` | 5.7 | Rewrite `.ts``.js`, `.tsx``.jsx`, `.mts``.mjs`, `.cts``.cjs` in relative imports during emit. Required when writing `.ts` imports for Node.js strip-types mode and still needing `.js` output for library distribution. |
| `--erasableSyntaxOnly` | 5.8 | Error on constructs that can't be type-stripped by Node.js `--experimental-strip-types`: `enum`, `namespace` with code, parameter properties, `import =` aliases. |
| `require()` of ESM under `--module nodenext` | 5.8 | Node.js 22+ allows CJS to `require()` ESM files (no top-level `await`). TypeScript now allows this under `nodenext` without error. |
| `import defer * as ns from "..."` | 5.9 | Defer module _evaluation_ (not loading) until first property access. Module is loaded and verified at import time; side-effects are delayed. Only works with `--module preserve` or `esnext`. |
| `Set` algebra methods | 5.5 | Non-mutating: `union`, `intersection`, `difference`, `symmetricDifference` → new `Set`. Predicate: `isSubsetOf`, `isSupersetOf`, `isDisjointFrom``boolean`. Requires `esnext` or `es2025` lib. |
| `Object.groupBy` / `Map.groupBy` | 5.4 | Group an iterable into buckets by key function. Return type has all keys as optional (not every key is guaranteed present). Requires `esnext` or `es2024`+ lib. |
| `Temporal` API types | 6.0 RC | `Temporal.Now`, `Temporal.Instant`, `Temporal.PlainDate`, etc. Available under `esnext` or `esnext.temporal` lib. Usable in runtimes that already ship it (V8 118+, SpiderMonkey, etc.). |
| `@satisfies` in JSDoc | 5.0 | Validates that a JS expression satisfies a type without widening it — the TS `satisfies` operator for `.js` files. Write `/** @satisfies {MyType} */` above the declaration or inline on a parenthesized expression. |
| `@overload` in JSDoc | 5.0 | Declare multiple call signatures for a JS function. Each JSDoc comment tagged `@overload` is treated as a distinct overload; the final JSDoc comment (without `@overload`) describes the implementation signature. |
| Getter/setter with completely unrelated types | 5.1 | `get style(): CSSStyleDeclaration` and `set style(v: string)` can now have fully unrelated types, provided both have explicit type annotations. Previously the getter type was required to be a subtype of the setter type. |
| `instanceof` narrowing via `Symbol.hasInstance` | 5.3 | When a class defines `static [Symbol.hasInstance](val: unknown): val is T`, the `instanceof` operator now narrows to the predicate type `T`, not the class type itself. Useful when the runtime check and the structural type differ. |
| Regex literal syntax checking | 5.5 | TypeScript validates regex literal syntax: malformed groups, nonexistent backreferences, named capture mismatches, and features not available at the current `--target`. No API needed; existing latent bugs surface as errors automatically. |
| `--build` continues past intermediate errors | 5.6 | `tsc --build` no longer stops at the first failing project. All projects are built and errors reported together. Use `--stopOnBuildErrors` to restore the old stop-on-first-error behavior. Useful for monorepos during upgrades. |
| `--module node18` | 5.8 | Stable `--module` flag for Node.js 18 semantics: disallows `require()` of ESM (unlike `nodenext`) and still allows import assertions. Use when pinned to Node 18 and not ready for `nodenext` behavior changes. |
| `--module node20` | 5.9 | Stable `--module` flag for Node.js 20 semantics: permits `require()` of ESM, rejects import assertions. Implies `--target es2023` (unlike `nodenext`, which floats to `esnext`). |
## Key APIs
### `Disposable` / `AsyncDisposable` / stacks (5.2)
Global types provided by TypeScript's lib (requires `esnext.disposable` or `esnext` in `lib`):
- `Disposable``{ [Symbol.dispose](): void }`
- `AsyncDisposable``{ [Symbol.asyncDispose](): PromiseLike<void> }`
- `DisposableStack``defer(fn)`, `use(resource)`, `adopt(value, disposeFn)`, `move()`. Is itself `Disposable`.
- `AsyncDisposableStack` — async equivalent. Is itself `AsyncDisposable`.
- `SuppressedError` — thrown when both the scope body and a `[Symbol.dispose]` throw. `.error` holds the dispose-phase error; `.suppressed` holds the original error.
Polyfill the symbols in older runtimes:
```ts
Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");
```
### Decorator context types (5.0)
Each decorator kind receives a typed context object as its second parameter:
- `ClassDecoratorContext`
- `ClassMethodDecoratorContext`
- `ClassGetterDecoratorContext`
- `ClassSetterDecoratorContext`
- `ClassFieldDecoratorContext`
- `ClassAccessorDecoratorContext`
All context objects have `.name`, `.kind`, `.static`, `.private`, and `.metadata`. Method/getter/setter/accessor contexts also have `.addInitializer(fn)` for running code at construction time.
### `IteratorObject` (5.6)
`IteratorObject<T, TReturn, TNext>` is the new type for built-in iterable iterators. Key methods: `map`, `filter`, `take`, `drop`, `flatMap`, `forEach`, `reduce`, `some`, `every`, `find`, `toArray`. Not the same as the pre-existing structural `Iterator<T>` protocol.
- Generators produce `Generator<T>` which extends `IteratorObject`.
- `Map.prototype.entries()` returns `MapIterator<[K, V]>`, `Set.prototype.values()` returns `SetIterator<T>`, etc.
- `Iterator.from(iterable)` converts any `Iterable` to an `IteratorObject`.
- `AsyncIteratorObject` exists for async parity.
- `--strictBuiltinIteratorReturn` (new `--strict`-mode flag in 5.6) makes the return type of `BuiltinIteratorReturn` be `undefined` instead of `any`, catching unchecked `done` access.
### Array copying methods (5.2)
Declared on `Array`, `ReadonlyArray`, and all `TypedArray` types. Use these instead of the mutating variants when you need to preserve the original:
| Mutating | Non-mutating copy |
| ---------------------------------- | ------------------------------------- |
| `arr.sort(cmp)` | `arr.toSorted(cmp)` |
| `arr.reverse()` | `arr.toReversed()` |
| `arr.splice(start, del, ...items)` | `arr.toSpliced(start, del, ...items)` |
| `arr[i] = v` | `arr.with(i, v)` |
## Pitfalls
Things easy to get wrong even when you know the modern API exists. Check your output against these.
**tsconfig defaults changed hard in 6.0.** `types: []` means no `@types/*` packages load implicitly. If you see floods of "cannot find name 'process'" or "cannot find module 'fs'" after upgrading to 6.0, add `"types": ["node"]` (or whatever you need) to `compilerOptions`. `rootDir: "."` means a project with source in `src/` will emit to `dist/src/` instead of `dist/` — add `"rootDir": "./src"` explicitly. `strict: true` by default means projects with loose code see new errors.
**`using` requires a runtime polyfill on older runtimes.** `Symbol.dispose` and `Symbol.asyncDispose` don't exist before Node.js 18.x / Chrome 120. Add the two-line polyfill at your entry point. `DisposableStack` and `AsyncDisposableStack` need a more substantial polyfill (e.g. from `@microsoft/using-polyfill`).
**`using` disposes in LIFO order.** Resources declared later in a scope are disposed first. Declare in the order you want reversed cleanup (acquisition order). `DisposableStack.defer` also runs in LIFO order.
**Inferred type predicates have if-and-only-if semantics.** `x => !!x` does NOT infer `x is NonNullable<T>` because `0`, `""`, and `false` are falsy but not absent. TypeScript correctly refuses the predicate. Use `x => x !== undefined` or `x => x !== null` for precise null/undefined filters. If a predicate isn't being inferred, the false branch is probably ambiguous.
**`--verbatimModuleSyntax` breaks CJS `require` emit.** Under this flag ESM `import`/`export` is emitted verbatim. You cannot produce `require()` calls from standard `import` syntax. For CJS output you must use `import foo = require("foo")` and `export = { ... }` syntax explicitly.
**`NoInfer<T>` doesn't prevent `T` from being resolved, only from being contributed at that position.** Other parameters can still infer `T`. It means "don't use me as an inference candidate", not "block `T` from being resolved".
**`--isolatedDeclarations` requires explicit return types on all exports.** Exported arrow functions, function declarations, and class methods all need annotations if their return type isn't trivially inferrable from a literal or type assertion. Editor quick-fixes can add them automatically.
**Standard decorators are incompatible with `--experimentalDecorators`.** Different type signatures, metadata model, and emit. A decorator written for one will not work with the other. `--emitDecoratorMetadata` is not supported with standard decorators. Don't mix the two systems in one project.
**`import defer` does not downlevel.** TypeScript does not transform `import defer` to polyfill-compatible code. The module is still _loaded_ eagerly (must exist); only _evaluation_ is deferred. Only use it under `--module preserve` or `esnext` with a runtime or bundler that supports it.
**`--erasableSyntaxOnly` prohibits parameter properties.** `constructor(public x: number)` is not allowed. Expand to an explicit field declaration plus assignment in the constructor body.
**Closure narrowing is invalidated if the variable is assigned anywhere in a nested function.** TypeScript cannot know when a nested function will run, so any assignment to a `let`/param inside a nested function — even a no-op like `value = value` — invalidates narrowing for all closures in the outer scope. Only the outer "no further assignments after this point" pattern is safe.
**Constant indexed access narrowing requires both `obj` and `key` to be unmodified between the check and the use.** If either is a `let` that could be reassigned, TypeScript will not narrow `obj[key]`. Extract the value to a `const` in that case.
**`switch (true)` narrowing does not carry across fall-through cases.** In a `switch (true)`, each `case` condition narrows independently. A variable narrowed in `case typeof x === "string":` that falls through to the next case will have its narrowing widened by the next condition, not accumulated from the previous one.
**`const` type parameter modifier falls back when constraint is mutable.** `<const T extends string[]>(args: T)` falls back to `string[]` because `readonly ["a", "b"]` isn't assignable to `string[]`. Use `<const T extends readonly string[]>` for arrays.
**`assert` import syntax errors under `--module nodenext` since 5.8.** Any remaining `import x from "..." assert { ... }` must be updated to `import x from "..." with { ... }`.
**`Array.prototype.filter(x => x !== null)` now narrows to non-null (5.5).** This is almost always correct, but if you intentionally needed the nullable type downstream, add an explicit annotation: `const items: (T | null)[] = arr.filter(x => x !== null)`.
## Behavioral changes that affect code
- **All enums are union enums** (5.0): Every enum member gets its own literal type. Out-of-domain literal assignment to an enum type now errors. Cross-enum assignment between enums with identical names but differing values now errors.
- **Relational operators no longer allow implicit string/number coercions** (5.0): `ns > 4` where `ns: number | string` is a type error. Use `+ns > 4` to explicitly coerce.
- **`--module`/`--moduleResolution` must agree on node flavor** (5.2): Mixing `--module nodenext` with `--moduleResolution bundler` is an error. Use `--module nodenext` alone or `--module esnext --moduleResolution bundler`.
- **Deprecations from 5.0 become hard errors in 5.5**: `--importsNotUsedAsValues`, `--preserveValueImports`, `--target ES3`, `--out`, and several others are fully removed in 5.5. They can no longer be specified, even with `"ignoreDeprecations": "5.0"`. Migrate to `--verbatimModuleSyntax` for the import flags.
- **Type-only imports conflicting with local values** (5.4): Under `--isolatedModules`, `import { Foo } from "..."` where a local `let Foo` also exists now errors. Use `import type { Foo }` or `import { type Foo }`.
- **Reference directives no longer synthesized or preserved in declaration emit** (5.5): `/// <reference types="node" />` TypeScript used to add automatically is no longer emitted. User-written directives are dropped unless they carry `preserve="true"`. Update library `tsconfig.json` if you relied on this.
- **`.mts` files never emit CJS; `.cts` files never emit ESM** (5.6): Regardless of `--module` setting. Previously the extension was ignored in some modes.
- **JSON imports under `--module nodenext` require `with { type: "json" }`** (5.7): `import data from "./config.json"` without the attribute is now a type error.
- **`TypedArray`s are now generic** (5.7): `Uint8Array` is `Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike>`. Code passing `Buffer` (from `@types/node`) to typed-array parameters may see new errors. Update `@types/node` to a version that matches.
- **`import assert { ... }` is an error under `--module nodenext`** (5.8): Node.js 22 dropped support for the old syntax. Use `with { ... }`.
- **`types` defaults to `[]` in 6.0**: All implicit `@types/*` loading stops. Add an explicit `"types": ["node"]` or the array will remain empty. Using `"types": ["*"]` restores the 5.x behavior.
- **`rootDir` defaults to `.` (the tsconfig directory) in 6.0**: Previously inferred from the common ancestor of all source files. Projects with `"include": ["./src"]` and no explicit `rootDir` will now emit into `dist/src/` instead of `dist/`. Add `"rootDir": "./src"` to fix.
- **`strict` defaults to `true` in 6.0**: Projects that were implicitly not strict will see new errors. Set `"strict": false` explicitly if you're not ready to fix them.
- **`--baseUrl` deprecated in 6.0** and no longer acts as a module resolution root. Add explicit prefixes to your `paths` entries instead.
- **`--moduleResolution node` (node10) deprecated in 6.0**: Removed in 7.0. Migrate to `nodenext` or `bundler`.
- **`amd`, `umd`, `systemjs`, `none` module targets deprecated in 6.0**: Removed in 7.0. Migrate to a bundler.
- **`--outFile` removed in 6.0**: Use a bundler (esbuild, Rollup, Webpack, etc.).
- **`module Foo { }` syntax removed in 6.0**: Rename all such declarations to `namespace Foo { }`.
- **`--esModuleInterop false` and `--allowSyntheticDefaultImports false` removed in 6.0**: Safe interop is now always on. Default imports from CJS modules (`import express from "express"`) are always valid.
- **Explicit `typeRoots` disables upward `node_modules/@types` fallback** (5.1): When `typeRoots` is specified and a lookup fails in those directories, TypeScript no longer walks parent directories for `@types`. If you relied on the fallback, add `"./node_modules/@types"` explicitly to your `typeRoots` array.
- **`super.` on instance field properties is a type error** (5.3): Calling `super.foo()` where `foo` is a class field (arrow function assigned in the constructor) rather than a prototype method now errors. Instance fields don't exist on the prototype; `super.field` is `undefined` at runtime.
- **`--build` always emits `.tsbuildinfo`** (5.6): Previously only written when `--incremental` or `--composite` was set. Now written unconditionally in any `--build` invocation. Update `.gitignore` or CI artifact management if needed.
- **`.mts`/`.cts` extensions and `package.json` `"type"` respected in all module modes** (5.6): Format-specific extensions and the `"type"` field inside `node_modules` are now honored regardless of `--module` setting (except `amd`, `umd`, `system`). A `.mts` file will never emit CJS output even under `--module commonjs`.
- **Granular return expression checking** (5.8): Each branch of a conditional expression (`cond ? a : b`) directly inside a `return` statement is now checked individually against the declared return type. Previously an `any`-typed branch could silently suppress type errors in the other branch.
+2 -2
View File
@@ -5,6 +5,6 @@ runs:
using: "composite"
steps:
- name: Install syft
uses: anchore/sbom-action/download-syft@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
syft-version: "v1.20.0"
syft-version: "v1.26.1"
+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"
-3
View File
@@ -82,9 +82,6 @@ updates:
mui:
patterns:
- "@mui*"
radix:
patterns:
- "@radix-ui/*"
react:
patterns:
- "react"
+29 -101
View File
@@ -181,7 +181,7 @@ jobs:
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
- name: golangci-lint cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
${{ env.LINT_CACHE_DIR }}
@@ -204,7 +204,7 @@ jobs:
# Needed for helm chart linting
- name: Install helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.9.2
continue-on-error: true
@@ -870,7 +870,7 @@ jobs:
# the check to pass. This is desired in PRs, but not in mainline.
- name: Publish to Chromatic (non-mainline)
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
uses: chromaui/action@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
STORYBOOK: true
@@ -902,7 +902,7 @@ jobs:
# infinitely "in progress" in mainline unless we re-review each build.
- name: Publish to Chromatic (mainline)
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
uses: chromaui/action@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
STORYBOOK: true
@@ -1316,122 +1316,50 @@ jobs:
"${IMAGE}"
done
# GitHub attestation provides SLSA provenance for the Docker images, establishing a verifiable
# record that these images were built in GitHub Actions with specific inputs and environment.
# This complements our existing cosign attestations which focus on SBOMs.
#
# We attest each tag separately to ensure all tags have proper provenance records.
# TODO: Consider refactoring these steps to use a matrix strategy or composite action to reduce duplication
# while maintaining the required functionality for each tag.
- name: Resolve Docker image digests for attestation
id: docker_digests
if: github.ref == 'refs/heads/main'
continue-on-error: true
env:
IMAGE_BASE: ghcr.io/coder/coder-preview
BUILD_TAG: ${{ steps.build-docker.outputs.tag }}
run: |
set -euxo pipefail
main_digest=$(docker buildx imagetools inspect --raw "${IMAGE_BASE}:main" | sha256sum | awk '{print "sha256:"$1}')
echo "main_digest=${main_digest}" >> "$GITHUB_OUTPUT"
latest_digest=$(docker buildx imagetools inspect --raw "${IMAGE_BASE}:latest" | sha256sum | awk '{print "sha256:"$1}')
echo "latest_digest=${latest_digest}" >> "$GITHUB_OUTPUT"
version_digest=$(docker buildx imagetools inspect --raw "${IMAGE_BASE}:${BUILD_TAG}" | sha256sum | awk '{print "sha256:"$1}')
echo "version_digest=${version_digest}" >> "$GITHUB_OUTPUT"
- name: GitHub Attestation for Docker image
id: attest_main
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' && steps.docker_digests.outputs.main_digest != ''
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: "ghcr.io/coder/coder-preview:main"
predicate-type: "https://slsa.dev/provenance/v1"
predicate: |
{
"buildType": "https://github.com/actions/runner-images/",
"builder": {
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
},
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": {
"sha1": "${{ github.sha }}"
},
"entryPoint": ".github/workflows/ci.yaml"
},
"environment": {
"github_workflow": "${{ github.workflow }}",
"github_run_id": "${{ github.run_id }}"
}
},
"metadata": {
"buildInvocationID": "${{ github.run_id }}",
"completeness": {
"environment": true,
"materials": true
}
}
}
subject-name: ghcr.io/coder/coder-preview
subject-digest: ${{ steps.docker_digests.outputs.main_digest }}
push-to-registry: true
- name: GitHub Attestation for Docker image (latest tag)
id: attest_latest
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' && steps.docker_digests.outputs.latest_digest != ''
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: "ghcr.io/coder/coder-preview:latest"
predicate-type: "https://slsa.dev/provenance/v1"
predicate: |
{
"buildType": "https://github.com/actions/runner-images/",
"builder": {
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
},
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": {
"sha1": "${{ github.sha }}"
},
"entryPoint": ".github/workflows/ci.yaml"
},
"environment": {
"github_workflow": "${{ github.workflow }}",
"github_run_id": "${{ github.run_id }}"
}
},
"metadata": {
"buildInvocationID": "${{ github.run_id }}",
"completeness": {
"environment": true,
"materials": true
}
}
}
subject-name: ghcr.io/coder/coder-preview
subject-digest: ${{ steps.docker_digests.outputs.latest_digest }}
push-to-registry: true
- name: GitHub Attestation for version-specific Docker image
id: attest_version
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' && steps.docker_digests.outputs.version_digest != ''
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
predicate-type: "https://slsa.dev/provenance/v1"
predicate: |
{
"buildType": "https://github.com/actions/runner-images/",
"builder": {
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
},
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": {
"sha1": "${{ github.sha }}"
},
"entryPoint": ".github/workflows/ci.yaml"
},
"environment": {
"github_workflow": "${{ github.workflow }}",
"github_run_id": "${{ github.run_id }}"
}
},
"metadata": {
"buildInvocationID": "${{ github.run_id }}",
"completeness": {
"environment": true,
"materials": true
}
}
}
subject-name: ghcr.io/coder/coder-preview
subject-digest: ${{ steps.docker_digests.outputs.version_digest }}
push-to-registry: true
# Report attestation failures but don't fail the workflow
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
AWS_DOGFOOD_DEPLOY_REGION: ${{ vars.AWS_DOGFOOD_DEPLOY_REGION }}
- name: Set up Flux CLI
uses: fluxcd/flux2/action@8454b02a32e48d775b9f563cb51fdcb1787b5b93 # v2.7.5
uses: fluxcd/flux2/action@871be9b40d53627786d3a3835a3ddba1e3234bd2 # v2.8.3
with:
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
version: "2.8.2"
+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}"
+73 -28
View File
@@ -4,23 +4,20 @@ on:
push:
branches:
- main
# This event reads the workflow from the default branch (main), not the
# release branch. No cherry-pick needed.
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release
release:
types: [published]
- "release/2.[0-9]+"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Queue rather than cancel so back-to-back pushes to main don't cancel the first sync.
cancel-in-progress: false
jobs:
sync:
name: Sync issues to Linear release
if: github.event_name == 'push'
sync-main:
name: Sync issues to next Linear release
if: github.event_name == 'push' && github.ref_name == 'main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -28,38 +25,86 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Detect next release version
id: version
# Find the highest release/2.X branch (exact pattern, no suffixes
# like release/2.31_hotfix) and derive the next minor version for
# the release currently in development on main.
run: |
LATEST_MINOR=$(git branch -r | grep -E '^\s*origin/release/2\.[0-9]+$' | \
sed 's/.*release\/2\.//' | sort -n | tail -1)
if [ -z "$LATEST_MINOR" ]; then
echo "No release branch found, skipping sync."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
NEXT="2.$((LATEST_MINOR + 1))"
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Detected next release: $NEXT"
- name: Sync issues
id: sync
uses: linear/linear-release-action@5cbaabc187ceb63eee9d446e62e68e5c29a03ae8 # v0.5.0
if: steps.version.outputs.skip != 'true'
uses: linear/linear-release-action@755d50b5adb7dd42b976ee9334952745d62ceb2d # v0.6.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: sync
version: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
timeout: 300
- name: Print release URL
if: steps.sync.outputs.release-url
run: echo "Synced to $RELEASE_URL"
env:
RELEASE_URL: ${{ steps.sync.outputs.release-url }}
sync-release-branch:
name: Sync backports to Linear release
if: github.event_name == 'push' && startsWith(github.ref_name, 'release/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
complete:
name: Complete Linear release
if: github.event_name == 'release'
- name: Extract release version
id: version
# The trigger only allows exact release/2.X branch names.
run: |
echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT"
- name: Sync issues
id: sync
uses: linear/linear-release-action@755d50b5adb7dd42b976ee9334952745d62ceb2d # v0.6.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: sync
version: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
timeout: 300
code-freeze:
name: Move Linear release to Code Freeze
needs: sync-release-branch
if: >
github.event_name == 'push' &&
startsWith(github.ref_name, 'release/') &&
github.event.created == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Complete release
id: complete
uses: linear/linear-release-action@5cbaabc187ceb63eee9d446e62e68e5c29a03ae8 # v0
- name: Extract release version
id: version
run: |
echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT"
- name: Move to Code Freeze
id: update
uses: linear/linear-release-action@755d50b5adb7dd42b976ee9334952745d62ceb2d # v0.6.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
command: update
stage: Code Freeze
version: ${{ steps.version.outputs.version }}
timeout: 300
- name: Print release URL
if: steps.complete.outputs.release-url
run: echo "Completed $RELEASE_URL"
env:
RELEASE_URL: ${{ steps.complete.outputs.release-url }}
+84 -117
View File
@@ -9,6 +9,7 @@ on:
options:
- mainline
- stable
- rc
release_notes:
description: Release notes for the publishing the release. This is required to create a release.
dry_run:
@@ -119,9 +120,19 @@ jobs:
exit 1
fi
# 2.10.2 -> release/2.10
# Derive the release branch from the version tag.
# Standard: 2.10.2 -> release/2.10
# RC: 2.32.0-rc.0 -> release/2.32-rc.0
version="$(./scripts/version.sh)"
release_branch=release/${version%.*}
if [[ "$version" == *-rc.* ]]; then
# Extract major.minor and rc suffix from e.g. 2.32.0-rc.0
base_version="${version%%-rc.*}" # 2.32.0
major_minor="${base_version%.*}" # 2.32
rc_suffix="${version##*-rc.}" # 0
release_branch="release/${major_minor}-rc.${rc_suffix}"
else
release_branch=release/${version%.*}
fi
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
if [[ -z "${branch_contains_tag}" ]]; then
echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?"
@@ -302,6 +313,7 @@ jobs:
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
id: build_base_image
if: steps.image-base-tag.outputs.tag != ''
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
@@ -349,48 +361,14 @@ jobs:
env:
IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }}
# GitHub attestation provides SLSA provenance for Docker images, establishing a verifiable
# record that these images were built in GitHub Actions with specific inputs and environment.
# This complements our existing cosign attestations (which focus on SBOMs) by adding
# GitHub-specific build provenance to enhance our supply chain security.
#
# TODO: Consider refactoring these attestation steps to use a matrix strategy or composite action
# to reduce duplication while maintaining the required functionality for each distinct image tag.
- name: GitHub Attestation for Base Docker image
id: attest_base
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
if: ${{ !inputs.dry_run && steps.build_base_image.outputs.digest != '' }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ steps.image-base-tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
predicate: |
{
"buildType": "https://github.com/actions/runner-images/",
"builder": {
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
},
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": {
"sha1": "${{ github.sha }}"
},
"entryPoint": ".github/workflows/release.yaml"
},
"environment": {
"github_workflow": "${{ github.workflow }}",
"github_run_id": "${{ github.run_id }}"
}
},
"metadata": {
"buildInvocationID": "${{ github.run_id }}",
"completeness": {
"environment": true,
"materials": true
}
}
}
subject-name: ghcr.io/coder/coder-base
subject-digest: ${{ steps.build_base_image.outputs.digest }}
push-to-registry: true
- name: Build Linux Docker images
@@ -413,7 +391,6 @@ jobs:
# being pushed so will automatically push them.
make push/build/coder_"$version"_linux.tag
# Save multiarch image tag for attestation
multiarch_image="$(./scripts/image_tag.sh)"
echo "multiarch_image=${multiarch_image}" >> "$GITHUB_OUTPUT"
@@ -424,12 +401,14 @@ jobs:
# version in the repo, also create a multi-arch image as ":latest" and
# push it
if [[ "$(git tag | grep '^v' | grep -vE '(rc|dev|-|\+|\/)' | sort -r --version-sort | head -n1)" == "v$(./scripts/version.sh)" ]]; then
latest_target="$(./scripts/image_tag.sh --version latest)"
# shellcheck disable=SC2046
./scripts/build_docker_multiarch.sh \
--push \
--target "$(./scripts/image_tag.sh --version latest)" \
--target "${latest_target}" \
$(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag)
echo "created_latest_tag=true" >> "$GITHUB_OUTPUT"
echo "latest_target=${latest_target}" >> "$GITHUB_OUTPUT"
else
echo "created_latest_tag=false" >> "$GITHUB_OUTPUT"
fi
@@ -450,7 +429,6 @@ jobs:
echo "Generating SBOM for multi-arch image: ${MULTIARCH_IMAGE}"
syft "${MULTIARCH_IMAGE}" -o spdx-json > "coder_${VERSION}_sbom.spdx.json"
# Attest SBOM to multi-arch image
echo "Attesting SBOM to multi-arch image: ${MULTIARCH_IMAGE}"
cosign clean --force=true "${MULTIARCH_IMAGE}"
cosign attest --type spdxjson \
@@ -472,85 +450,42 @@ jobs:
"${latest_tag}"
fi
- name: GitHub Attestation for Docker image
id: attest_main
- name: Resolve Docker image digests for attestation
id: docker_digests
if: ${{ !inputs.dry_run }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
predicate-type: "https://slsa.dev/provenance/v1"
predicate: |
{
"buildType": "https://github.com/actions/runner-images/",
"builder": {
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
},
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": {
"sha1": "${{ github.sha }}"
},
"entryPoint": ".github/workflows/release.yaml"
},
"environment": {
"github_workflow": "${{ github.workflow }}",
"github_run_id": "${{ github.run_id }}"
}
},
"metadata": {
"buildInvocationID": "${{ github.run_id }}",
"completeness": {
"environment": true,
"materials": true
}
}
}
push-to-registry: true
env:
MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }}
LATEST_TARGET: ${{ steps.build_docker.outputs.latest_target }}
run: |
set -euxo pipefail
if [[ -n "${MULTIARCH_IMAGE}" ]]; then
multiarch_digest=$(docker buildx imagetools inspect --raw "${MULTIARCH_IMAGE}" | sha256sum | awk '{print "sha256:"$1}')
echo "multiarch_digest=${multiarch_digest}" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${LATEST_TARGET}" ]]; then
latest_digest=$(docker buildx imagetools inspect --raw "${LATEST_TARGET}" | sha256sum | awk '{print "sha256:"$1}')
echo "latest_digest=${latest_digest}" >> "$GITHUB_OUTPUT"
fi
# Get the latest tag name for attestation
- name: Get latest tag name
id: latest_tag
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
run: echo "tag=$(./scripts/image_tag.sh --version latest)" >> "$GITHUB_OUTPUT"
# If this is the highest version according to semver, also attest the "latest" tag
- name: GitHub Attestation for "latest" Docker image
id: attest_latest
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
- name: GitHub Attestation for Docker image
id: attest_main
if: ${{ !inputs.dry_run && steps.docker_digests.outputs.multiarch_digest != '' }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ steps.latest_tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
predicate: |
{
"buildType": "https://github.com/actions/runner-images/",
"builder": {
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
},
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": {
"sha1": "${{ github.sha }}"
},
"entryPoint": ".github/workflows/release.yaml"
},
"environment": {
"github_workflow": "${{ github.workflow }}",
"github_run_id": "${{ github.run_id }}"
}
},
"metadata": {
"buildInvocationID": "${{ github.run_id }}",
"completeness": {
"environment": true,
"materials": true
}
}
}
subject-name: ghcr.io/coder/coder
subject-digest: ${{ steps.docker_digests.outputs.multiarch_digest }}
push-to-registry: true
- name: GitHub Attestation for "latest" Docker image
id: attest_latest
if: ${{ !inputs.dry_run && steps.docker_digests.outputs.latest_digest != '' }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ghcr.io/coder/coder
subject-digest: ${{ steps.docker_digests.outputs.latest_digest }}
push-to-registry: true
# Report attestation failures but don't fail the workflow
@@ -607,6 +542,9 @@ jobs:
if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then
publish_args+=(--stable)
fi
if [[ $CODER_RELEASE_CHANNEL == "rc" ]]; then
publish_args+=(--rc)
fi
if [[ $CODER_DRY_RUN == *t* ]]; then
publish_args+=(--dry-run)
fi
@@ -639,6 +577,35 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }}
# Mark the Linear release as shipped.
- name: Extract Linear release version
if: ${{ !inputs.dry_run }}
id: linear_version
run: |
# Skip RC releases — they must not complete the Linear release.
if [[ "$VERSION" == *-rc* ]]; then
echo "RC release (${VERSION}), skipping Linear release completion."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Strip patch to get the Linear release version (e.g. 2.32.0 -> 2.32).
linear_version=$(echo "$VERSION" | cut -d. -f1,2)
echo "version=$linear_version" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Completing Linear release ${linear_version}"
env:
VERSION: ${{ steps.version.outputs.version }}
- name: Complete Linear release
if: ${{ !inputs.dry_run && steps.linear_version.outputs.skip != 'true' }}
continue-on-error: true
uses: linear/linear-release-action@755d50b5adb7dd42b976ee9334952745d62ceb2d # v0.6.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ steps.linear_version.outputs.version }}
timeout: 300
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
@@ -690,7 +657,7 @@ jobs:
retention-days: 7
- name: Send repository-dispatch event
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }}
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
@@ -778,7 +745,7 @@ jobs:
name: Publish to winget-pkgs
runs-on: windows-latest
needs: release
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }}
steps:
- name: Harden Runner
+2 -2
View File
@@ -125,7 +125,7 @@ jobs:
egress-policy: audit
- name: Delete PR Cleanup workflow runs
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
uses: Mattraks/delete-workflow-runs@b3018382ca039b53d238908238bd35d1fb14f8ee # v2.1.0
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
@@ -134,7 +134,7 @@ jobs:
delete_workflow_pattern: pr-cleanup.yaml
- name: Delete PR Deploy workflow skipped runs
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
uses: Mattraks/delete-workflow-runs@b3018382ca039b53d238908238bd35d1fb14f8ee # v2.1.0
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
+9 -1
View File
@@ -46,8 +46,16 @@ jobs:
echo " replacement: \"https://github.com/coder/coder/tree/${HEAD_SHA}/\""
} >> .github/.linkspector.yml
# TODO: Remove this workaround once action-linkspector sets
# package-manager-cache: false in its internal setup-node step.
# See: https://github.com/UmbrellaDocs/action-linkspector/issues/54
- name: Enable corepack and create pnpm store
run: |
corepack enable pnpm
mkdir -p "$(pnpm store path --silent)"
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0
uses: umbrelladocs/action-linkspector@37c85bcde51b30bf929936502bac6bfb7e8f0a4d # v1.4.1
id: markdown-link-check
# checks all markdown files from /docs including all subfolders
with:
+1
View File
@@ -54,6 +54,7 @@ site/stats/
*.tfstate.backup
*.tfplan
*.lock.hcl
!provisioner/terraform/testdata/resources/.terraform.lock.hcl
.terraform/
!coderd/testdata/parameters/modules/.terraform/
!provisioner/terraform/testdata/modules-source-caching/.terraform/
+13 -2
View File
@@ -988,6 +988,7 @@ coderd/httpmw/loggermw/loggermock/loggermock.go: coderd/httpmw/loggermw/logger.g
codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agentconn.go
go generate ./codersdk/workspacesdk/agentconnmock/
./scripts/format_go_file.sh "$@"
touch "$@"
$(AIBRIDGED_MOCKS): enterprise/aibridged/client.go enterprise/aibridged/pool.go
@@ -1260,11 +1261,21 @@ provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/tes
touch "$@"
provisioner/terraform/testdata/version:
if [[ "$(shell cat provisioner/terraform/testdata/version.txt)" != "$(shell terraform version -json | jq -r '.terraform_version')" ]]; then
./provisioner/terraform/testdata/generate.sh
@tf_match=true; \
if [[ "$$(cat provisioner/terraform/testdata/version.txt)" != \
"$$(terraform version -json | jq -r '.terraform_version')" ]]; then \
tf_match=false; \
fi; \
if ! $$tf_match || \
! ./provisioner/terraform/testdata/generate.sh --check; then \
./provisioner/terraform/testdata/generate.sh; \
fi
.PHONY: provisioner/terraform/testdata/version
update-terraform-testdata:
./provisioner/terraform/testdata/generate.sh --upgrade
.PHONY: update-terraform-testdata
# Set the retry flags if TEST_RETRIES is set
ifdef TEST_RETRIES
GOTESTSUM_RETRY_FLAGS := --rerun-fails=$(TEST_RETRIES)
+29 -4
View File
@@ -38,6 +38,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/clistat"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentcontextconfig"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentfiles"
"github.com/coder/coder/v2/agent/agentgit"
@@ -50,6 +51,7 @@ import (
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
"github.com/coder/coder/v2/agent/reconnectingpty"
"github.com/coder/coder/v2/agent/x/agentdesktop"
"github.com/coder/coder/v2/agent/x/agentmcp"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/gitauth"
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -307,10 +309,13 @@ type agent struct {
containerAPI *agentcontainers.API
gitAPIOptions []agentgit.Option
filesAPI *agentfiles.API
gitAPI *agentgit.API
processAPI *agentproc.API
desktopAPI *agentdesktop.API
filesAPI *agentfiles.API
gitAPI *agentgit.API
processAPI *agentproc.API
desktopAPI *agentdesktop.API
mcpManager *agentmcp.Manager
mcpAPI *agentmcp.API
contextConfigAPI *agentcontextconfig.API
socketServerEnabled bool
socketPath string
@@ -396,6 +401,14 @@ func (a *agent) init() {
a.logger.Named("desktop"), a.execer, a.scriptRunner.ScriptBinDir(),
)
a.desktopAPI = agentdesktop.NewAPI(a.logger.Named("desktop"), desktop, a.clock)
a.mcpManager = agentmcp.NewManager(a.logger.Named("mcp"))
a.mcpAPI = agentmcp.NewAPI(a.logger.Named("mcp"), a.mcpManager)
a.contextConfigAPI = agentcontextconfig.NewAPI(func() string {
if m := a.manifest.Load(); m != nil {
return m.Directory
}
return ""
})
a.reconnectingPTYServer = reconnectingpty.NewServer(
a.logger.Named("reconnecting-pty"),
a.sshServer,
@@ -1348,6 +1361,14 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
}
a.metrics.startupScriptSeconds.WithLabelValues(label).Set(dur)
a.scriptRunner.StartCron()
// Connect to workspace MCP servers after the
// lifecycle transition to avoid delaying Ready.
// This runs inside the tracked goroutine so it
// is properly awaited on shutdown.
if mcpErr := a.mcpManager.Connect(a.gracefulCtx, a.contextConfigAPI.Config().MCPConfigFiles); mcpErr != nil {
a.logger.Warn(ctx, "failed to connect to workspace MCP servers", slog.Error(mcpErr))
}
})
if err != nil {
return xerrors.Errorf("track conn goroutine: %w", err)
@@ -2070,6 +2091,10 @@ func (a *agent) Close() error {
a.logger.Error(a.hardCtx, "desktop API close", slog.Error(err))
}
if err := a.mcpManager.Close(); err != nil {
a.logger.Error(a.hardCtx, "mcp manager close", slog.Error(err))
}
if a.boundaryLogProxy != nil {
err = a.boundaryLogProxy.Close()
if err != nil {
+52
View File
@@ -1,6 +1,8 @@
package agent
import (
"path/filepath"
"runtime"
"testing"
"github.com/google/uuid"
@@ -8,10 +10,22 @@ import (
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontextconfig"
"github.com/coder/coder/v2/agent/proto"
agentsdk "github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/testutil"
)
// platformAbsPath constructs an absolute path that is valid
// on the current platform. On Windows, paths must include a
// drive letter to be considered absolute.
func platformAbsPath(parts ...string) string {
if runtime.GOOS == "windows" {
return `C:\` + filepath.Join(parts...)
}
return "/" + filepath.Join(parts...)
}
// TestReportConnectionEmpty tests that reportConnection() doesn't choke if given an empty IP string, which is what we
// send if we cannot get the remote address.
func TestReportConnectionEmpty(t *testing.T) {
@@ -42,3 +56,41 @@ func TestReportConnectionEmpty(t *testing.T) {
require.Equal(t, proto.Connection_DISCONNECT, req1.GetConnection().GetAction())
require.Equal(t, "because", req1.GetConnection().GetReason())
}
func TestContextConfigAPI_InitOnce(t *testing.T) {
// Not parallel: uses t.Setenv to clear env vars.
// Clear env vars so defaults are used and the test is
// hermetic regardless of the surrounding environment.
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
// After the fix, contextConfigAPI is set once in init() and
// never reassigned. Config() evaluates lazily via the
// manifest, so there is no concurrent write to race with.
dir1 := platformAbsPath("dir1")
dir2 := platformAbsPath("dir2")
a := &agent{}
a.manifest.Store(&agentsdk.Manifest{Directory: dir1})
a.contextConfigAPI = agentcontextconfig.NewAPI(func() string {
if m := a.manifest.Load(); m != nil {
return m.Directory
}
return ""
})
cfg1 := a.contextConfigAPI.Config()
require.NotEmpty(t, cfg1.MCPConfigFiles)
require.Contains(t, cfg1.MCPConfigFiles[0], dir1)
// Simulate manifest update on reconnection — no field
// reassignment needed, the lazy closure picks it up.
a.manifest.Store(&agentsdk.Manifest{Directory: dir2})
cfg2 := a.contextConfigAPI.Config()
require.NotEmpty(t, cfg2.MCPConfigFiles)
require.Contains(t, cfg2.MCPConfigFiles[0], dir2)
}
+15 -8
View File
@@ -3007,7 +3007,7 @@ func TestAgent_Speedtest(t *testing.T) {
func TestAgent_Reconnect(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
// After the agent is disconnected from a coordinator, it's supposed
// to reconnect!
@@ -3020,7 +3020,8 @@ func TestAgent_Reconnect(t *testing.T) {
logger,
agentID,
agentsdk.Manifest{
DERPMap: derpMap,
DERPMap: derpMap,
Directory: "/test/workspace",
},
statsCh,
fCoordinator,
@@ -3033,13 +3034,19 @@ func TestAgent_Reconnect(t *testing.T) {
})
defer closer.Close()
call1 := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
require.Equal(t, client.GetNumRefreshTokenCalls(), 1)
close(call1.Resps) // hang up
// expect reconnect
// Each iteration forces the agent to reconnect by closing
// the current coordinate call while the tracked HTTP server
// goroutine (from connection 1's createTailnet) is still
// alive, widening the race window.
const reconnections = 5
for i := range reconnections {
call := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
require.Equal(t, i+1, client.GetNumRefreshTokenCalls())
close(call.Resps) // hang up — triggers reconnect
}
// Verify final reconnect succeeds.
testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
// Check that the agent refreshes the token when it reconnects.
require.Equal(t, client.GetNumRefreshTokenCalls(), 2)
require.Equal(t, reconnections+1, client.GetNumRefreshTokenCalls())
closer.Close()
}
@@ -159,7 +159,6 @@ func TestConvertDockerVolume(t *testing.T) {
func TestConvertDockerInspect(t *testing.T) {
t.Parallel()
//nolint:paralleltest // variable recapture no longer required
for _, tt := range []struct {
name string
expect []codersdk.WorkspaceAgentContainer
@@ -388,7 +387,6 @@ func TestConvertDockerInspect(t *testing.T) {
},
},
} {
// nolint:paralleltest // variable recapture no longer required
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
bs, err := os.ReadFile(filepath.Join("testdata", tt.name, "docker_inspect.json"))
-2
View File
@@ -166,7 +166,6 @@ func TestDockerEnvInfoer(t *testing.T) {
pool, err := dockertest.NewPool("")
require.NoError(t, err, "Could not connect to docker")
// nolint:paralleltest // variable recapture no longer required
for idx, tt := range []struct {
image string
labels map[string]string
@@ -223,7 +222,6 @@ func TestDockerEnvInfoer(t *testing.T) {
expectedUserShell: "/bin/bash",
},
} {
//nolint:paralleltest // variable recapture no longer required
t.Run(fmt.Sprintf("#%d", idx), func(t *testing.T) {
// Start a container with the given image
// and environment variables
+84
View File
@@ -0,0 +1,84 @@
package agentcontextconfig
import (
"cmp"
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// Env var names for context configuration. Prefixed with EXP_
// to indicate these are experimental and may change.
const (
EnvInstructionsDirs = "CODER_AGENT_EXP_INSTRUCTIONS_DIRS"
EnvInstructionsFile = "CODER_AGENT_EXP_INSTRUCTIONS_FILE"
EnvSkillsDirs = "CODER_AGENT_EXP_SKILLS_DIRS"
EnvSkillMetaFile = "CODER_AGENT_EXP_SKILL_META_FILE"
EnvMCPConfigFiles = "CODER_AGENT_EXP_MCP_CONFIG_FILES"
)
// Defaults are defined in codersdk/workspacesdk so both
// the agent and server can reference them without a
// cross-layer import.
// API exposes the resolved context configuration through the
// agent's HTTP API.
type API struct {
workingDir func() string
}
// NewAPI accepts a closure that returns the working directory.
// The directory is evaluated lazily on each call to Config(),
// so the caller can update it after construction.
func NewAPI(workingDir func() string) *API {
if workingDir == nil {
workingDir = func() string { return "" }
}
return &API{workingDir: workingDir}
}
// Config reads env vars and resolves paths. Exported for use
// by the MCP manager and tests.
func Config(workingDir string) workspacesdk.ContextConfigResponse {
// TrimSpace all env vars before cmp.Or so that a
// whitespace-only value falls through to the default
// consistently. ResolvePaths also trims each comma-
// separated entry, but without pre-trimming here a
// bare " " would bypass cmp.Or and produce nil.
instructionsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsDirs)), workspacesdk.DefaultInstructionsDir)
instructionsFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsFile)), workspacesdk.DefaultInstructionsFile)
skillsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillsDirs)), workspacesdk.DefaultSkillsDir)
skillMetaFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillMetaFile)), workspacesdk.DefaultSkillMetaFile)
mcpConfigFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvMCPConfigFiles)), workspacesdk.DefaultMCPConfigFile)
return workspacesdk.ContextConfigResponse{
InstructionsDirs: ResolvePaths(instructionsDir, workingDir),
InstructionsFile: instructionsFile,
SkillsDirs: ResolvePaths(skillsDir, workingDir),
SkillMetaFile: skillMetaFile,
MCPConfigFiles: ResolvePaths(mcpConfigFile, workingDir),
}
}
// Config returns the resolved config for use by other agent
// components (e.g. MCP manager).
func (api *API) Config() workspacesdk.ContextConfigResponse {
return Config(api.workingDir())
}
// Routes returns the HTTP handler for the context config
// endpoint.
func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/", api.handleGet)
return r
}
func (api *API) handleGet(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, api.Config())
}
+117
View File
@@ -0,0 +1,117 @@
package agentcontextconfig_test
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontextconfig"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func TestConfig(t *testing.T) {
t.Run("Defaults", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
// Clear all env vars so defaults are used.
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := platformAbsPath("work")
cfg := agentcontextconfig.Config(workDir)
require.Equal(t, workspacesdk.DefaultInstructionsFile, cfg.InstructionsFile)
require.Equal(t, workspacesdk.DefaultSkillMetaFile, cfg.SkillMetaFile)
// Default instructions dir is "~/.coder" which resolves
// to the home directory.
require.Equal(t, []string{filepath.Join(fakeHome, ".coder")}, cfg.InstructionsDirs)
// Default skills dir is ".agents/skills" (relative),
// resolved against the working directory.
require.Equal(t, []string{filepath.Join(workDir, ".agents", "skills")}, cfg.SkillsDirs)
// Default MCP config file is ".mcp.json" (relative),
// resolved against the working directory.
require.Equal(t, []string{filepath.Join(workDir, ".mcp.json")}, cfg.MCPConfigFiles)
})
t.Run("CustomEnvVars", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
optInstructions := platformAbsPath("opt", "instructions")
optSkills := platformAbsPath("opt", "skills")
optMCP := platformAbsPath("opt", "mcp.json")
t.Setenv(agentcontextconfig.EnvInstructionsDirs, optInstructions)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "CUSTOM.md")
t.Setenv(agentcontextconfig.EnvSkillsDirs, optSkills)
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "META.yaml")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, optMCP)
workDir := platformAbsPath("work")
cfg := agentcontextconfig.Config(workDir)
require.Equal(t, "CUSTOM.md", cfg.InstructionsFile)
require.Equal(t, "META.yaml", cfg.SkillMetaFile)
require.Equal(t, []string{optInstructions}, cfg.InstructionsDirs)
require.Equal(t, []string{optSkills}, cfg.SkillsDirs)
require.Equal(t, []string{optMCP}, cfg.MCPConfigFiles)
})
t.Run("WhitespaceInFileNames", func(t *testing.T) {
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, " CLAUDE.md ")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := platformAbsPath("work")
cfg := agentcontextconfig.Config(workDir)
require.Equal(t, "CLAUDE.md", cfg.InstructionsFile)
})
t.Run("CommaSeparatedDirs", func(t *testing.T) {
a := platformAbsPath("opt", "a")
b := platformAbsPath("opt", "b")
t.Setenv(agentcontextconfig.EnvInstructionsDirs, a+","+b)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := platformAbsPath("work")
cfg := agentcontextconfig.Config(workDir)
require.Equal(t, []string{a, b}, cfg.InstructionsDirs)
})
}
func TestNewAPI_LazyDirectory(t *testing.T) {
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
dir := ""
api := agentcontextconfig.NewAPI(func() string { return dir })
// Before directory is set, relative paths resolve to nothing.
cfg := api.Config()
require.Empty(t, cfg.SkillsDirs)
require.Empty(t, cfg.MCPConfigFiles)
// After setting the directory, Config() picks it up lazily.
dir = platformAbsPath("work")
cfg = api.Config()
require.NotEmpty(t, cfg.SkillsDirs)
require.Equal(t, []string{filepath.Join(dir, ".agents", "skills")}, cfg.SkillsDirs)
}
+55
View File
@@ -0,0 +1,55 @@
package agentcontextconfig
import (
"os"
"path/filepath"
"strings"
)
// ResolvePath resolves a single path that may be absolute,
// home-relative (~/ or ~), or relative to the given base
// directory. Returns an absolute path. Empty input returns empty.
func ResolvePath(raw, baseDir string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
switch {
case raw == "~":
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return home
case strings.HasPrefix(raw, "~/"):
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, raw[2:])
case filepath.IsAbs(raw):
return raw
default:
if baseDir == "" {
return ""
}
return filepath.Join(baseDir, raw)
}
}
// ResolvePaths splits a comma-separated list of paths and
// resolves each entry independently. Empty entries and entries
// that resolve to empty strings are skipped.
func ResolvePaths(raw, baseDir string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if resolved := ResolvePath(p, baseDir); resolved != "" {
out = append(out, resolved)
}
}
return out
}
+152
View File
@@ -0,0 +1,152 @@
package agentcontextconfig_test
import (
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontextconfig"
)
// platformAbsPath constructs an absolute path that is valid
// on the current platform. On Windows paths must include a
// drive letter to be considered absolute.
func platformAbsPath(parts ...string) string {
if runtime.GOOS == "windows" {
return `C:\` + filepath.Join(parts...)
}
return "/" + filepath.Join(parts...)
}
func TestResolvePath(t *testing.T) { //nolint:tparallel // subtests using t.Setenv cannot be parallel
t.Run("EmptyInput", func(t *testing.T) {
t.Parallel()
require.Equal(t, "", agentcontextconfig.ResolvePath("", platformAbsPath("base")))
})
t.Run("WhitespaceOnly", func(t *testing.T) {
t.Parallel()
require.Equal(t, "", agentcontextconfig.ResolvePath(" ", platformAbsPath("base")))
})
// Tests that use t.Setenv cannot be parallel.
t.Run("TildeAlone", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
got := agentcontextconfig.ResolvePath("~", platformAbsPath("base"))
require.Equal(t, fakeHome, got)
})
t.Run("TildeSlashPath", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
got := agentcontextconfig.ResolvePath("~/docs/readme", platformAbsPath("base"))
require.Equal(t, filepath.Join(fakeHome, "docs", "readme"), got)
})
t.Run("AbsolutePath", func(t *testing.T) {
t.Parallel()
p := platformAbsPath("etc", "coder")
got := agentcontextconfig.ResolvePath(p, platformAbsPath("base"))
require.Equal(t, p, got)
})
t.Run("RelativePath", func(t *testing.T) {
t.Parallel()
base := platformAbsPath("work")
got := agentcontextconfig.ResolvePath("foo/bar", base)
require.Equal(t, filepath.Join(base, "foo", "bar"), got)
})
t.Run("RelativePathWithWhitespace", func(t *testing.T) {
t.Parallel()
base := platformAbsPath("work")
got := agentcontextconfig.ResolvePath(" foo/bar ", base)
require.Equal(t, filepath.Join(base, "foo", "bar"), got)
})
t.Run("RelativePathWithEmptyBaseDir", func(t *testing.T) {
t.Parallel()
got := agentcontextconfig.ResolvePath(".agents/skills", "")
require.Equal(t, "", got)
})
}
func TestResolvePath_HomeUnset(t *testing.T) {
// Cannot be parallel — modifies HOME env var.
t.Setenv("HOME", "")
// Also clear USERPROFILE for Windows compatibility.
t.Setenv("USERPROFILE", "")
require.Equal(t, "", agentcontextconfig.ResolvePath("~", platformAbsPath("base")))
require.Equal(t, "", agentcontextconfig.ResolvePath("~/docs", platformAbsPath("base")))
}
func TestResolvePaths(t *testing.T) { //nolint:tparallel // subtests using t.Setenv cannot be parallel
t.Run("EmptyString", func(t *testing.T) {
t.Parallel()
require.Nil(t, agentcontextconfig.ResolvePaths("", platformAbsPath("base")))
})
t.Run("WhitespaceOnly", func(t *testing.T) {
t.Parallel()
require.Nil(t, agentcontextconfig.ResolvePaths(" ", platformAbsPath("base")))
})
t.Run("SingleEntry", func(t *testing.T) {
t.Parallel()
p := platformAbsPath("abs", "path")
got := agentcontextconfig.ResolvePaths(p, platformAbsPath("base"))
require.Equal(t, []string{p}, got)
})
// Tests that use t.Setenv cannot be parallel.
t.Run("MultipleEntries", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
b := platformAbsPath("b")
base := platformAbsPath("base")
got := agentcontextconfig.ResolvePaths("~/a,"+b+",rel", base)
require.Equal(t, []string{
filepath.Join(fakeHome, "a"),
b,
filepath.Join(base, "rel"),
}, got)
})
t.Run("TrimsWhitespace", func(t *testing.T) {
t.Parallel()
a := platformAbsPath("a")
b := platformAbsPath("b")
got := agentcontextconfig.ResolvePaths(" "+a+" , "+b+" ", platformAbsPath("base"))
require.Equal(t, []string{a, b}, got)
})
t.Run("SkipsEmptyEntries", func(t *testing.T) {
t.Parallel()
a := platformAbsPath("a")
b := platformAbsPath("b")
got := agentcontextconfig.ResolvePaths(a+",,"+b+",", platformAbsPath("base"))
require.Equal(t, []string{a, b}, got)
})
t.Run("TrailingComma", func(t *testing.T) {
t.Parallel()
p := platformAbsPath("only")
got := agentcontextconfig.ResolvePaths(p+",", platformAbsPath("base"))
require.Equal(t, []string{p}, got)
})
t.Run("RelativePathSkippedWhenBaseDirEmpty", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
got := agentcontextconfig.ResolvePaths("~/.coder,.agents/skills", "")
require.Equal(t, []string{filepath.Join(fakeHome, ".coder")}, got)
})
}
+5
View File
@@ -148,6 +148,11 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
for k, v := range req.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
// Propagate the chat ID so child processes (e.g.
// GIT_ASKPASS) can send it back to the server.
if chatID != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("CODER_CHAT_ID=%s", chatID))
}
if err := cmd.Start(); err != nil {
cancel()
+1 -1
View File
@@ -211,7 +211,7 @@ func TestServer_X11_EvictionLRU(t *testing.T) {
require.NoError(t, err)
stderr, err := sess.StderrPipe()
require.NoError(t, err)
require.NoError(t, sess.Shell())
require.NoError(t, sess.Start("sh"))
// The SSH server lazily starts the session. We need to write a command
// and read back to ensure the X11 forwarding is started.
+2
View File
@@ -31,6 +31,8 @@ func (a *agent) apiHandler() http.Handler {
r.Mount("/api/v0/git", a.gitAPI.Routes())
r.Mount("/api/v0/processes", a.processAPI.Routes())
r.Mount("/api/v0/desktop", a.desktopAPI.Routes())
r.Mount("/api/v0/mcp", a.mcpAPI.Routes())
r.Mount("/api/v0/context-config", a.contextConfigAPI.Routes())
if a.devcontainers {
r.Mount("/api/v0/containers", a.containerAPI.Routes())
+88
View File
@@ -0,0 +1,88 @@
package agentmcp
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// API exposes MCP tool discovery and call proxying through the
// agent.
type API struct {
logger slog.Logger
manager *Manager
}
// NewAPI creates a new MCP API handler backed by the given
// manager.
func NewAPI(logger slog.Logger, manager *Manager) *API {
return &API{
logger: logger,
manager: manager,
}
}
// Routes returns the HTTP handler for MCP-related routes.
func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/tools", api.handleListTools)
r.Post("/call-tool", api.handleCallTool)
return r
}
// handleListTools returns the cached MCP tool definitions,
// optionally refreshing them first if ?refresh=true is set.
func (api *API) handleListTools(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Allow callers to force a tool re-scan before listing.
if r.URL.Query().Get("refresh") == "true" {
if err := api.manager.RefreshTools(ctx); err != nil {
api.logger.Warn(ctx, "failed to refresh MCP tools", slog.Error(err))
}
}
tools := api.manager.Tools()
// Ensure non-nil so JSON serialization returns [] not null.
if tools == nil {
tools = []workspacesdk.MCPToolInfo{}
}
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ListMCPToolsResponse{
Tools: tools,
})
}
// handleCallTool proxies a tool invocation to the appropriate
// MCP server based on the tool name prefix.
func (api *API) handleCallTool(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req workspacesdk.CallMCPToolRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
resp, err := api.manager.CallTool(ctx, req)
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, ErrInvalidToolName) {
status = http.StatusBadRequest
} else if errors.Is(err, ErrUnknownServer) {
status = http.StatusNotFound
}
httpapi.Write(ctx, rw, status, codersdk.Response{
Message: "MCP tool call failed.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
+115
View File
@@ -0,0 +1,115 @@
package agentmcp
import (
"encoding/json"
"os"
"slices"
"strings"
"golang.org/x/xerrors"
)
// ServerConfig describes a single MCP server parsed from a .mcp.json file.
type ServerConfig struct {
Name string `json:"name"`
Transport string `json:"type"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
}
// mcpConfigFile mirrors the on-disk .mcp.json schema.
type mcpConfigFile struct {
MCPServers map[string]json.RawMessage `json:"mcpServers"`
}
// mcpServerEntry is a single server block inside mcpServers.
type mcpServerEntry struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
Type string `json:"type"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
}
// ParseConfig reads a .mcp.json file at path and returns the declared
// MCP servers sorted by name. It returns an empty slice when the
// mcpServers key is missing or empty.
func ParseConfig(path string) ([]ServerConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, xerrors.Errorf("read mcp config %q: %w", path, err)
}
var cfg mcpConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, xerrors.Errorf("parse mcp config %q: %w", path, err)
}
if len(cfg.MCPServers) == 0 {
return []ServerConfig{}, nil
}
servers := make([]ServerConfig, 0, len(cfg.MCPServers))
for name, raw := range cfg.MCPServers {
var entry mcpServerEntry
if err := json.Unmarshal(raw, &entry); err != nil {
return nil, xerrors.Errorf("parse server %q in %q: %w", name, path, err)
}
if strings.Contains(name, ToolNameSep) || strings.HasPrefix(name, "_") || strings.HasSuffix(name, "_") {
return nil, xerrors.Errorf("server name %q in %q contains reserved separator %q or leading/trailing underscore", name, path, ToolNameSep)
}
transport := inferTransport(entry)
if transport == "" {
return nil, xerrors.Errorf("server %q in %q has no command or url", name, path)
}
resolveEnvVars(entry.Env)
servers = append(servers, ServerConfig{
Name: name,
Transport: transport,
Command: entry.Command,
Args: entry.Args,
Env: entry.Env,
URL: entry.URL,
Headers: entry.Headers,
})
}
slices.SortFunc(servers, func(a, b ServerConfig) int {
return strings.Compare(a.Name, b.Name)
})
return servers, nil
}
// inferTransport determines the transport type for a server entry.
// An explicit "type" field takes priority; otherwise the presence
// of "command" implies stdio and "url" implies http.
func inferTransport(e mcpServerEntry) string {
if e.Type != "" {
return e.Type
}
if e.Command != "" {
return "stdio"
}
if e.URL != "" {
return "http"
}
return ""
}
// resolveEnvVars expands ${VAR} references in env map values
// using the current process environment.
func resolveEnvVars(env map[string]string) {
for k, v := range env {
env[k] = os.Expand(v, os.Getenv)
}
}
+254
View File
@@ -0,0 +1,254 @@
package agentmcp_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/x/agentmcp"
)
func TestParseConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
expected []agentmcp.ServerConfig
expectError bool
}{
{
name: "StdioServer",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"my-server": map[string]any{
"command": "npx",
"args": []string{"-y", "@example/mcp-server"},
"env": map[string]string{"FOO": "bar"},
},
},
}),
expected: []agentmcp.ServerConfig{
{
Name: "my-server",
Transport: "stdio",
Command: "npx",
Args: []string{"-y", "@example/mcp-server"},
Env: map[string]string{"FOO": "bar"},
},
},
},
{
name: "HTTPServer",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"remote": map[string]any{
"url": "https://example.com/mcp",
"headers": map[string]string{"Authorization": "Bearer tok"},
},
},
}),
expected: []agentmcp.ServerConfig{
{
Name: "remote",
Transport: "http",
URL: "https://example.com/mcp",
Headers: map[string]string{"Authorization": "Bearer tok"},
},
},
},
{
name: "SSEServer",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"events": map[string]any{
"type": "sse",
"url": "https://example.com/sse",
},
},
}),
expected: []agentmcp.ServerConfig{
{
Name: "events",
Transport: "sse",
URL: "https://example.com/sse",
},
},
},
{
name: "ExplicitTypeOverridesInference",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"hybrid": map[string]any{
"command": "some-binary",
"type": "http",
},
},
}),
expected: []agentmcp.ServerConfig{
{
Name: "hybrid",
Transport: "http",
Command: "some-binary",
},
},
},
{
name: "EnvVarPassthrough",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"srv": map[string]any{
"command": "run",
"env": map[string]string{"PLAIN": "literal-value"},
},
},
}),
expected: []agentmcp.ServerConfig{
{
Name: "srv",
Transport: "stdio",
Command: "run",
Env: map[string]string{"PLAIN": "literal-value"},
},
},
},
{
name: "EmptyMCPServers",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{},
}),
expected: []agentmcp.ServerConfig{},
},
{
name: "MalformedJSON",
content: `{not valid json`,
expectError: true,
},
{
name: "ServerNameContainsSeparator",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"bad__name": map[string]any{"command": "run"},
},
}),
expectError: true,
},
{
name: "ServerNameTrailingUnderscore",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"server_": map[string]any{"command": "run"},
},
}),
expectError: true,
},
{
name: "ServerNameLeadingUnderscore",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"_server": map[string]any{"command": "run"},
},
}),
expectError: true,
},
{
name: "EmptyTransport", content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"empty": map[string]any{},
},
}),
expectError: true,
},
{
name: "MissingMCPServersKey",
content: mustJSON(t, map[string]any{
"servers": map[string]any{},
}),
expected: []agentmcp.ServerConfig{},
},
{
name: "MultipleServersSortedByName",
content: mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"zeta": map[string]any{"command": "z"},
"alpha": map[string]any{"command": "a"},
"mu": map[string]any{"command": "m"},
},
}),
expected: []agentmcp.ServerConfig{
{Name: "alpha", Transport: "stdio", Command: "a"},
{Name: "mu", Transport: "stdio", Command: "m"},
{Name: "zeta", Transport: "stdio", Command: "z"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, ".mcp.json")
err := os.WriteFile(path, []byte(tt.content), 0o600)
require.NoError(t, err)
got, err := agentmcp.ParseConfig(path)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.expected, got)
})
}
}
// TestParseConfig_EnvVarInterpolation verifies that ${VAR} references
// in env values are resolved from the process environment. This test
// cannot be parallel because t.Setenv is incompatible with t.Parallel.
func TestParseConfig_EnvVarInterpolation(t *testing.T) {
t.Setenv("TEST_MCP_TOKEN", "secret123")
content := mustJSON(t, map[string]any{
"mcpServers": map[string]any{
"srv": map[string]any{
"command": "run",
"env": map[string]string{"TOKEN": "${TEST_MCP_TOKEN}"},
},
},
})
dir := t.TempDir()
path := filepath.Join(dir, ".mcp.json")
err := os.WriteFile(path, []byte(content), 0o600)
require.NoError(t, err)
got, err := agentmcp.ParseConfig(path)
require.NoError(t, err)
require.Equal(t, []agentmcp.ServerConfig{
{
Name: "srv",
Transport: "stdio",
Command: "run",
Env: map[string]string{"TOKEN": "secret123"},
},
}, got)
}
func TestParseConfig_FileNotFound(t *testing.T) {
t.Parallel()
_, err := agentmcp.ParseConfig(filepath.Join(t.TempDir(), "nonexistent.json"))
require.Error(t, err)
}
// mustJSON marshals v to a JSON string, failing the test on error.
func mustJSON(t *testing.T, v any) string {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
return string(data)
}
+470
View File
@@ -0,0 +1,470 @@
package agentmcp
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// ToolNameSep separates the server name from the original tool name
// in prefixed tool names. Double underscore avoids collisions with
// tool names that may contain single underscores.
const ToolNameSep = "__"
// connectTimeout bounds how long we wait for a single MCP server
// to start its transport and complete initialization.
const connectTimeout = 30 * time.Second
// toolCallTimeout bounds how long a single tool invocation may
// take before being canceled.
const toolCallTimeout = 60 * time.Second
var (
// ErrInvalidToolName is returned when the tool name format
// is not "server__tool".
ErrInvalidToolName = xerrors.New("invalid tool name format")
// ErrUnknownServer is returned when no MCP server matches
// the prefix in the tool name.
ErrUnknownServer = xerrors.New("unknown MCP server")
)
// Manager manages connections to MCP servers discovered from a
// workspace's .mcp.json file. It caches the aggregated tool list
// and proxies tool calls to the appropriate server.
type Manager struct {
mu sync.RWMutex
logger slog.Logger
closed bool
servers map[string]*serverEntry // keyed by server name
tools []workspacesdk.MCPToolInfo
}
// serverEntry pairs a server config with its connected client.
type serverEntry struct {
config ServerConfig
client *client.Client
}
// NewManager creates a new MCP client manager.
func NewManager(logger slog.Logger) *Manager {
return &Manager{
logger: logger,
servers: make(map[string]*serverEntry),
}
}
// Connect reads MCP config files at the given absolute paths and
// connects to all configured servers. Failed servers are logged
// and skipped. Missing config files are silently skipped.
func (m *Manager) Connect(ctx context.Context, mcpConfigFiles []string) error {
var allConfigs []ServerConfig
for _, configPath := range mcpConfigFiles {
configs, err := ParseConfig(configPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
m.logger.Warn(ctx, "failed to parse MCP config",
slog.F("path", configPath),
slog.Error(err),
)
continue
}
allConfigs = append(allConfigs, configs...)
}
// Deduplicate by server name; first occurrence wins.
seen := make(map[string]struct{})
deduped := make([]ServerConfig, 0, len(allConfigs))
for _, cfg := range allConfigs {
if _, ok := seen[cfg.Name]; ok {
continue
}
seen[cfg.Name] = struct{}{}
deduped = append(deduped, cfg)
}
allConfigs = deduped
if len(allConfigs) == 0 {
return nil
}
// Connect to servers in parallel without holding the
// lock, since each connectServer call may block on
// network I/O for up to connectTimeout.
type connectedServer struct {
name string
config ServerConfig
client *client.Client
}
var (
mu sync.Mutex
connected []connectedServer
)
var eg errgroup.Group
for _, cfg := range allConfigs {
eg.Go(func() error {
c, err := m.connectServer(ctx, cfg)
if err != nil {
m.logger.Warn(ctx, "skipping MCP server",
slog.F("server", cfg.Name),
slog.F("transport", cfg.Transport),
slog.Error(err),
)
return nil // Don't fail the group.
}
mu.Lock()
connected = append(connected, connectedServer{
name: cfg.Name, config: cfg, client: c,
})
mu.Unlock()
return nil
})
}
_ = eg.Wait()
m.mu.Lock()
if m.closed {
m.mu.Unlock()
// Close the freshly-connected clients since we're
// shutting down.
for _, cs := range connected {
_ = cs.client.Close()
}
return xerrors.New("manager closed")
}
// Close previous connections to avoid leaking child
// processes on agent reconnect.
for _, entry := range m.servers {
_ = entry.client.Close()
}
m.servers = make(map[string]*serverEntry, len(connected))
for _, cs := range connected {
m.servers[cs.name] = &serverEntry{
config: cs.config,
client: cs.client,
}
}
m.mu.Unlock()
// Refresh tools outside the lock to avoid blocking
// concurrent reads during network I/O.
if err := m.RefreshTools(ctx); err != nil {
m.logger.Warn(ctx, "failed to refresh MCP tools after connect", slog.Error(err))
}
return nil
}
// connectServer establishes a connection to a single MCP server
// and returns the connected client. It does not modify any Manager
// state.
func (*Manager) connectServer(ctx context.Context, cfg ServerConfig) (*client.Client, error) {
tr, err := createTransport(cfg)
if err != nil {
return nil, xerrors.Errorf("create transport for %q: %w", cfg.Name, err)
}
c := client.NewClient(tr)
connectCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
if err := c.Start(connectCtx); err != nil {
_ = c.Close()
return nil, xerrors.Errorf("start %q: %w", cfg.Name, err)
}
_, err = c.Initialize(connectCtx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
Name: "coder-agent",
Version: buildinfo.Version(),
},
},
})
if err != nil {
_ = c.Close()
return nil, xerrors.Errorf("initialize %q: %w", cfg.Name, err)
}
return c, nil
}
// createTransport builds the mcp-go transport for a server config.
func createTransport(cfg ServerConfig) (transport.Interface, error) {
switch cfg.Transport {
case "stdio":
return transport.NewStdio(
cfg.Command,
buildEnv(cfg.Env),
cfg.Args...,
), nil
case "http", "":
return transport.NewStreamableHTTP(
cfg.URL,
transport.WithHTTPHeaders(cfg.Headers),
)
case "sse":
return transport.NewSSE(
cfg.URL,
transport.WithHeaders(cfg.Headers),
)
default:
return nil, xerrors.Errorf("unsupported transport %q", cfg.Transport)
}
}
// buildEnv merges the current process environment with explicit
// overrides, returning the result as KEY=VALUE strings suitable
// for the stdio transport.
func buildEnv(explicit map[string]string) []string {
env := os.Environ()
if len(explicit) == 0 {
return env
}
// Index existing env so explicit keys can override in-place.
existing := make(map[string]int, len(env))
for i, kv := range env {
if k, _, ok := strings.Cut(kv, "="); ok {
existing[k] = i
}
}
for k, v := range explicit {
entry := k + "=" + v
if idx, ok := existing[k]; ok {
env[idx] = entry
} else {
env = append(env, entry)
}
}
return env
}
// Tools returns the cached tool list. Thread-safe.
func (m *Manager) Tools() []workspacesdk.MCPToolInfo {
m.mu.RLock()
defer m.mu.RUnlock()
return slices.Clone(m.tools)
}
// CallTool proxies a tool call to the appropriate MCP server.
func (m *Manager) CallTool(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
serverName, originalName, err := splitToolName(req.ToolName)
if err != nil {
return workspacesdk.CallMCPToolResponse{}, err
}
m.mu.RLock()
entry, ok := m.servers[serverName]
m.mu.RUnlock()
if !ok {
return workspacesdk.CallMCPToolResponse{}, xerrors.Errorf("%w: %q", ErrUnknownServer, serverName)
}
callCtx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
result, err := entry.client.CallTool(callCtx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: originalName,
Arguments: req.Arguments,
},
})
if err != nil {
return workspacesdk.CallMCPToolResponse{}, xerrors.Errorf("call tool %q on %q: %w", originalName, serverName, err)
}
return convertResult(result), nil
}
// splitToolName extracts the server name and original tool name
// from a prefixed tool name like "server__tool".
func splitToolName(prefixed string) (serverName, toolName string, err error) {
server, tool, ok := strings.Cut(prefixed, ToolNameSep)
if !ok || server == "" || tool == "" {
return "", "", xerrors.Errorf("%w: expected format \"server%stool\", got %q", ErrInvalidToolName, ToolNameSep, prefixed)
}
return server, tool, nil
}
// convertResult translates an MCP CallToolResult into a
// workspacesdk.CallMCPToolResponse. It iterates over content
// items and maps each recognized type.
func convertResult(result *mcp.CallToolResult) workspacesdk.CallMCPToolResponse {
if result == nil {
return workspacesdk.CallMCPToolResponse{}
}
var content []workspacesdk.MCPToolContent
for _, item := range result.Content {
switch c := item.(type) {
case mcp.TextContent:
content = append(content, workspacesdk.MCPToolContent{
Type: "text",
Text: c.Text,
})
case mcp.ImageContent:
content = append(content, workspacesdk.MCPToolContent{
Type: "image",
Data: c.Data,
MediaType: c.MIMEType,
})
case mcp.AudioContent:
content = append(content, workspacesdk.MCPToolContent{
Type: "audio",
Data: c.Data,
MediaType: c.MIMEType,
})
case mcp.EmbeddedResource:
content = append(content, workspacesdk.MCPToolContent{
Type: "resource",
Text: fmt.Sprintf("[embedded resource: %T]", c.Resource),
})
case mcp.ResourceLink:
content = append(content, workspacesdk.MCPToolContent{
Type: "resource",
Text: fmt.Sprintf("[resource link: %s]", c.URI),
})
default:
content = append(content, workspacesdk.MCPToolContent{
Type: "text",
Text: fmt.Sprintf("[unsupported content type: %T]", item),
})
}
}
return workspacesdk.CallMCPToolResponse{
Content: content,
IsError: result.IsError,
}
}
// RefreshTools re-fetches tool lists from all connected servers
// in parallel and rebuilds the cache. On partial failure, tools
// from servers that responded successfully are merged with the
// existing cached tools for servers that failed, so a single
// dead server doesn't block updates from healthy ones.
func (m *Manager) RefreshTools(ctx context.Context) error {
// Snapshot servers under read lock.
m.mu.RLock()
servers := make(map[string]*serverEntry, len(m.servers))
for k, v := range m.servers {
servers[k] = v
}
m.mu.RUnlock()
// Fetch tool lists in parallel without holding any lock.
type serverTools struct {
name string
tools []workspacesdk.MCPToolInfo
}
var (
mu sync.Mutex
results []serverTools
failed []string
errs []error
)
var eg errgroup.Group
for name, entry := range servers {
eg.Go(func() error {
listCtx, cancel := context.WithTimeout(ctx, connectTimeout)
result, err := entry.client.ListTools(listCtx, mcp.ListToolsRequest{})
cancel()
if err != nil {
m.logger.Warn(ctx, "failed to list tools from MCP server",
slog.F("server", name),
slog.Error(err),
)
mu.Lock()
errs = append(errs, xerrors.Errorf("list tools from %q: %w", name, err))
failed = append(failed, name)
mu.Unlock()
return nil
}
var tools []workspacesdk.MCPToolInfo
for _, tool := range result.Tools {
tools = append(tools, workspacesdk.MCPToolInfo{
ServerName: name,
Name: name + ToolNameSep + tool.Name,
Description: tool.Description,
Schema: tool.InputSchema.Properties,
Required: tool.InputSchema.Required,
})
}
mu.Lock()
results = append(results, serverTools{name: name, tools: tools})
mu.Unlock()
return nil
})
}
_ = eg.Wait()
// Build the new tool list. For servers that failed, preserve
// their tools from the existing cache so a single dead server
// doesn't remove healthy tools.
var merged []workspacesdk.MCPToolInfo
for _, st := range results {
merged = append(merged, st.tools...)
}
if len(failed) > 0 {
failedSet := make(map[string]struct{}, len(failed))
for _, f := range failed {
failedSet[f] = struct{}{}
}
m.mu.RLock()
for _, t := range m.tools {
if _, ok := failedSet[t.ServerName]; ok {
merged = append(merged, t)
}
}
m.mu.RUnlock()
}
slices.SortFunc(merged, func(a, b workspacesdk.MCPToolInfo) int {
return strings.Compare(a.Name, b.Name)
})
m.mu.Lock()
m.tools = merged
m.mu.Unlock()
return errors.Join(errs...)
}
// Close terminates all MCP server connections and child
// processes.
func (m *Manager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
m.closed = true
var errs []error
for _, entry := range m.servers {
errs = append(errs, entry.client.Close())
}
m.servers = make(map[string]*serverEntry)
m.tools = nil
return errors.Join(errs...)
}
+195
View File
@@ -0,0 +1,195 @@
package agentmcp
import (
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func TestSplitToolName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantServer string
wantTool string
wantErr bool
}{
{
name: "Valid",
input: "server__tool",
wantServer: "server",
wantTool: "tool",
},
{
name: "ValidWithUnderscoresInTool",
input: "server__my_tool",
wantServer: "server",
wantTool: "my_tool",
},
{
name: "MissingSeparator",
input: "servertool",
wantErr: true,
},
{
name: "EmptyServer",
input: "__tool",
wantErr: true,
},
{
name: "EmptyTool",
input: "server__",
wantErr: true,
},
{
name: "JustSeparator",
input: "__",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server, tool, err := splitToolName(tt.input)
if tt.wantErr {
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidToolName)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantServer, server)
assert.Equal(t, tt.wantTool, tool)
})
}
}
func TestConvertResult(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// input is a pointer so we can test nil.
input *mcp.CallToolResult
want workspacesdk.CallMCPToolResponse
}{
{
name: "NilInput",
input: nil,
want: workspacesdk.CallMCPToolResponse{},
},
{
name: "TextContent",
input: &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: "hello"},
},
},
want: workspacesdk.CallMCPToolResponse{
Content: []workspacesdk.MCPToolContent{
{Type: "text", Text: "hello"},
},
},
},
{
name: "ImageContent",
input: &mcp.CallToolResult{
Content: []mcp.Content{
mcp.ImageContent{
Type: "image",
Data: "base64data",
MIMEType: "image/png",
},
},
},
want: workspacesdk.CallMCPToolResponse{
Content: []workspacesdk.MCPToolContent{
{Type: "image", Data: "base64data", MediaType: "image/png"},
},
},
},
{
name: "AudioContent",
input: &mcp.CallToolResult{
Content: []mcp.Content{
mcp.AudioContent{
Type: "audio",
Data: "base64audio",
MIMEType: "audio/mp3",
},
},
},
want: workspacesdk.CallMCPToolResponse{
Content: []workspacesdk.MCPToolContent{
{Type: "audio", Data: "base64audio", MediaType: "audio/mp3"},
},
},
},
{
name: "IsErrorPropagation",
input: &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: "fail"},
},
IsError: true,
},
want: workspacesdk.CallMCPToolResponse{
Content: []workspacesdk.MCPToolContent{
{Type: "text", Text: "fail"},
},
IsError: true,
},
},
{
name: "MultipleContentItems",
input: &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: "caption"},
mcp.ImageContent{
Type: "image",
Data: "imgdata",
MIMEType: "image/jpeg",
},
},
},
want: workspacesdk.CallMCPToolResponse{
Content: []workspacesdk.MCPToolContent{
{Type: "text", Text: "caption"},
{Type: "image", Data: "imgdata", MediaType: "image/jpeg"},
},
},
},
{
name: "ResourceLink",
input: &mcp.CallToolResult{
Content: []mcp.Content{
mcp.ResourceLink{
Type: "resource_link",
URI: "file:///tmp/test.txt",
},
},
},
want: workspacesdk.CallMCPToolResponse{
Content: []workspacesdk.MCPToolContent{
{Type: "resource", Text: "[resource link: file:///tmp/test.txt]"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := convertResult(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
+4 -1
View File
@@ -173,7 +173,10 @@ func Start(t *testing.T, inv *serpent.Invocation) {
StartWithAssert(t, inv, nil)
}
func StartWithAssert(t *testing.T, inv *serpent.Invocation, assertCallback func(t *testing.T, err error)) { //nolint:revive
// StartWithAssert starts the given invocation and calls assertCallback
// with the resulting error when the invocation completes. If assertCallback
// is nil, expected shutdown errors are silently tolerated.
func StartWithAssert(t *testing.T, inv *serpent.Invocation, assertCallback func(t *testing.T, err error)) {
t.Helper()
closeCh := make(chan struct{})
-2
View File
@@ -173,7 +173,6 @@ func (selectModel) Init() tea.Cmd {
return nil
}
//nolint:revive // The linter complains about modifying 'm' but this is typical practice for bubbletea
func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
@@ -463,7 +462,6 @@ func (multiSelectModel) Init() tea.Cmd {
return nil
}
//nolint:revive // For same reason as previous Update definition
func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
-1
View File
@@ -1414,7 +1414,6 @@ func tailLineStyle() pretty.Style {
return pretty.Style{pretty.Nop}
}
//nolint:unused
func SlimUnsupported(w io.Writer, cmd string) {
_, _ = fmt.Fprintf(w, "You are using a 'slim' build of Coder, which does not support the %s subcommand.\n", pretty.Sprint(cliui.DefaultStyles.Code, cmd))
_, _ = fmt.Fprintln(w, "")
+13 -2
View File
@@ -352,8 +352,6 @@ func TestScheduleOverride(t *testing.T) {
require.NoError(t, err, "invalid schedule")
ownerClient, _, _, ws := setupTestSchedule(t, sched)
now := time.Now()
// To avoid the likelihood of time-related flakes, only matching up to the hour.
expectedDeadline := now.In(loc).Add(10 * time.Hour).Format("2006-01-02T15:")
// When: we override the stop schedule
inv, root := clitest.New(t,
@@ -364,6 +362,19 @@ func TestScheduleOverride(t *testing.T) {
pty := ptytest.New(t).Attach(inv)
require.NoError(t, inv.Run())
// Fetch the workspace to get the actual deadline set by the
// server. Computing our own expected deadline from a separately
// captured time.Now() is racy: the CLI command calls time.Now()
// internally, and with the Asia/Kolkata +05:30 offset the hour
// boundary falls at :30 UTC minutes. A small delay between our
// time.Now() and the command's is enough to land in different
// hours.
updated, err := ownerClient.Workspace(context.Background(), ws[0].ID)
require.NoError(t, err)
require.False(t, updated.LatestBuild.Deadline.IsZero(), "deadline should be set after extend")
require.WithinDuration(t, now.Add(10*time.Hour), updated.LatestBuild.Deadline.Time, 5*time.Minute)
expectedDeadline := updated.LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
+2 -5
View File
@@ -305,7 +305,6 @@ func enablePrometheus(
}
options.ProvisionerdServerMetrics = provisionerdserverMetrics
//nolint:revive
return ServeHandler(
ctx, logger, promhttp.InstrumentMetricHandler(
options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}),
@@ -1637,8 +1636,6 @@ var defaultCipherSuites = func() []uint16 {
// configureServerTLS returns the TLS config used for the Coderd server
// connections to clients. A logger is passed in to allow printing warning
// messages that do not block startup.
//
//nolint:revive
func configureServerTLS(ctx context.Context, logger slog.Logger, tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string, ciphers []string, allowInsecureCiphers bool) (*tls.Config, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
@@ -2055,7 +2052,6 @@ func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *c
return &params, nil
}
//nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive)
func configureGithubOAuth2(instrument *promoauth.Factory, params *githubOAuth2ConfigParams) (*coderd.GithubOAuth2Config, error) {
redirectURL, err := params.accessURL.Parse("/api/v2/users/oauth2/github/callback")
if err != nil {
@@ -2331,7 +2327,8 @@ func ConfigureHTTPClient(ctx context.Context, clientCertFile, clientKeyFile stri
return ctx, nil, err
}
tlsClientConfig := &tls.Config{ //nolint:gosec
tlsClientConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: certificates,
NextProtos: []string{"h2", "http/1.1"},
}
-1
View File
@@ -2123,7 +2123,6 @@ func TestServer_TelemetryDisable(t *testing.T) {
// Set the default telemetry to true (normally disabled in tests).
t.Setenv("CODER_TEST_TELEMETRY_DEFAULT_ENABLE", "true")
//nolint:paralleltest // No need to reinitialise the variable tt (Go version).
for _, tt := range []struct {
key string
val string
+31
View File
@@ -165,6 +165,37 @@ func TestSyncCommands_Golden(t *testing.T) {
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/want_success", outBuf.Bytes(), nil)
})
t.Run("want_multiple_deps", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
defer cleanup()
ctx := testutil.Context(t, testutil.WaitShort)
var outBuf bytes.Buffer
inv, _ := clitest.New(t, "exp", "sync", "want", "test-unit", "dep-1", "dep-2", "dep-3", "--socket-path", path)
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// Verify all dependencies were registered by checking status.
outBuf.Reset()
inv, _ = clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path, "--output", "json")
inv.Stdout = &outBuf
inv.Stderr = &outBuf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
// The output should mention all three dependencies.
output := outBuf.String()
require.Contains(t, output, "dep-1")
require.Contains(t, output, "dep-2")
require.Contains(t, output, "dep-3")
})
t.Run("complete", func(t *testing.T) {
t.Parallel()
path, cleanup := setupSocketServer(t)
+9 -8
View File
@@ -11,17 +11,16 @@ import (
func (*RootCmd) syncWant(socketPath *string) *serpent.Command {
cmd := &serpent.Command{
Use: "want <unit> <depends-on>",
Short: "Declare that a unit depends on another unit completing before it can start",
Long: "Declare that a unit depends on another unit completing before it can start. The unit specified first will not start until the second has signaled that it has completed.",
Use: "want <unit> <depends-on> [depends-on...]",
Short: "Declare that a unit depends on other units completing before it can start",
Long: "Declare that a unit depends on one or more other units completing before it can start. The unit specified first will not start until all subsequent units have signaled that they have completed.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
if len(i.Args) != 2 {
return xerrors.New("exactly two arguments are required: unit and depends-on")
if len(i.Args) < 2 {
return xerrors.New("at least two arguments are required: unit and one or more depends-on")
}
dependentUnit := unit.ID(i.Args[0])
dependsOn := unit.ID(i.Args[1])
opts := []agentsocket.Option{}
if *socketPath != "" {
@@ -34,8 +33,10 @@ func (*RootCmd) syncWant(socketPath *string) *serpent.Command {
}
defer client.Close()
if err := client.SyncWant(ctx, dependentUnit, dependsOn); err != nil {
return xerrors.Errorf("declare dependency failed: %w", err)
for _, dep := range i.Args[1:] {
if err := client.SyncWant(ctx, dependentUnit, unit.ID(dep)); err != nil {
return xerrors.Errorf("declare dependency failed: %w", err)
}
}
cliui.Info(i.Stdout, "Success")
+2 -2
View File
@@ -828,7 +828,7 @@ func TestTemplateEdit(t *testing.T) {
"--require-active-version",
}
inv, root := clitest.New(t, cmdArgs...)
//nolint
//nolint:gocritic // Using owner client is required for template editing.
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -858,7 +858,7 @@ func TestTemplateEdit(t *testing.T) {
"--name", "something-new",
}
inv, root := clitest.New(t, cmdArgs...)
//nolint
//nolint:gocritic // Using owner client is required for template editing.
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitLong)
+1 -1
View File
@@ -16,7 +16,7 @@ SUBCOMMANDS:
ping Test agent socket connectivity and health
start Wait until all unit dependencies are satisfied
status Show unit status and dependency state
want Declare that a unit depends on another unit completing before it
want Declare that a unit depends on other units completing before it
can start
OPTIONS:
+5 -5
View File
@@ -1,13 +1,13 @@
coder v0.0.0-devel
USAGE:
coder exp sync want <unit> <depends-on>
coder exp sync want <unit> <depends-on> [depends-on...]
Declare that a unit depends on another unit completing before it can start
Declare that a unit depends on other units completing before it can start
Declare that a unit depends on another unit completing before it can start.
The unit specified first will not start until the second has signaled that it
has completed.
Declare that a unit depends on one or more other units completing before it
can start. The unit specified first will not start until all subsequent units
have signaled that they have completed.
———
Run `coder --help` for a list of global options.
+4 -2
View File
@@ -17,7 +17,8 @@
"name": "owner",
"display_name": "Owner"
}
]
],
"has_ai_seat": false
},
{
"id": "==========[second user ID]==========",
@@ -31,6 +32,7 @@
"organization_ids": [
"===========[first org ID]==========="
],
"roles": []
"roles": [],
"has_ai_seat": false
}
]
+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)
-1
View File
@@ -101,7 +101,6 @@ func TestConnectionLog(t *testing.T) {
reason: "because error says so",
},
}
//nolint:paralleltest // No longer necessary to reinitialise the variable tt.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
+3
View File
@@ -3,6 +3,7 @@ package agentapi
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -60,6 +61,8 @@ func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.B
}
)
for _, md := range req.Metadata {
md.Result.Value = strings.TrimSpace(md.Result.Value)
md.Result.Error = strings.TrimSpace(md.Result.Error)
metadataError := md.Result.Error
allKeysLen += len(md.Key)
+32 -4
View File
@@ -57,16 +57,44 @@ func TestBatchUpdateMetadata(t *testing.T) {
CollectedAt: timestamppb.New(now.Add(-3 * time.Second)),
Age: 3,
Value: "",
Error: "uncool value",
Error: "\t uncool error ",
},
},
},
}
batchSize := len(req.Metadata)
// This test sends 2 metadata entries. With batch size 2, we expect
// exactly 1 capacity flush.
// This test sends 2 metadata entries (one clean, one with
// whitespace padding). With batch size 2 we expect exactly
// 1 capacity flush. The matcher verifies that stored values
// are trimmed while clean values pass through unchanged.
expectedValues := map[string]string{
"awesome key": "awesome value",
"uncool key": "",
}
expectedErrors := map[string]string{
"awesome key": "",
"uncool key": "uncool error",
}
store.EXPECT().
BatchUpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).
BatchUpdateWorkspaceAgentMetadata(
gomock.Any(),
gomock.Cond(func(arg database.BatchUpdateWorkspaceAgentMetadataParams) bool {
if len(arg.Key) != len(expectedValues) {
return false
}
for i, key := range arg.Key {
expVal, ok := expectedValues[key]
if !ok || arg.Value[i] != expVal {
return false
}
expErr, ok := expectedErrors[key]
if !ok || arg.Error[i] != expErr {
return false
}
}
return true
}),
).
Return(nil).
Times(1)
+19
View File
@@ -16,6 +16,25 @@ import (
// that use per-user LLM credentials but cannot set custom headers.
const HeaderCoderToken = "X-Coder-AI-Governance-Token" //nolint:gosec // This is a header name, not a credential.
// HeaderCoderRequestID is a header set by aibridgeproxyd on each
// 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 {
+267
View File
@@ -84,6 +84,34 @@ const docTemplate = `{
}
}
},
"/aibridge/clients": {
"get": {
"produces": [
"application/json"
],
"tags": [
"AI Bridge"
],
"summary": "List AI Bridge clients",
"operationId": "list-ai-bridge-clients",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/aibridge/interceptions": {
"get": {
"produces": [
@@ -214,6 +242,58 @@ const docTemplate = `{
]
}
},
"/aibridge/sessions/{session_id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"AI Bridge"
],
"summary": "Get AI Bridge session threads",
"operationId": "get-ai-bridge-session-threads",
"parameters": [
{
"type": "string",
"description": "Session ID (client_session_id or interception UUID)",
"name": "session_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Thread pagination cursor (forward/older)",
"name": "after_id",
"in": "query"
},
{
"type": "string",
"description": "Thread pagination cursor (backward/newer)",
"name": "before_id",
"in": "query"
},
{
"type": "integer",
"description": "Number of threads per page (default 50)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/appearance": {
"get": {
"produces": [
@@ -12675,6 +12755,29 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeAgenticAction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"thinking": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeModelThought"
}
},
"token_usage": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsTokenUsage"
},
"tool_calls": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeToolCall"
}
}
}
},
"codersdk.AIBridgeAnthropicConfig": {
"type": "object",
"properties": {
@@ -12791,6 +12894,9 @@ const docTemplate = `{
"provider": {
"type": "string"
},
"provider_name": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
@@ -12843,6 +12949,14 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeModelThought": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
}
},
"codersdk.AIBridgeOpenAIConfig": {
"type": "object",
"properties": {
@@ -12942,6 +13056,76 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeSessionThreadsResponse": {
"type": "object",
"properties": {
"client": {
"type": "string"
},
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string"
},
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"page_ended_at": {
"type": "string",
"format": "date-time"
},
"page_started_at": {
"type": "string",
"format": "date-time"
},
"providers": {
"type": "array",
"items": {
"type": "string"
}
},
"started_at": {
"type": "string",
"format": "date-time"
},
"threads": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeThread"
}
},
"token_usage_summary": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsTokenUsage"
}
}
},
"codersdk.AIBridgeSessionThreadsTokenUsage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"output_tokens": {
"type": "integer"
}
}
},
"codersdk.AIBridgeSessionTokenUsageSummary": {
"type": "object",
"properties": {
@@ -12953,6 +13137,41 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeThread": {
"type": "object",
"properties": {
"agentic_actions": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeAgenticAction"
}
},
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"model": {
"type": "string"
},
"prompt": {
"type": "string"
},
"provider": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"token_usage": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsTokenUsage"
}
}
},
"codersdk.AIBridgeTokenUsage": {
"type": "object",
"properties": {
@@ -12983,6 +13202,42 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeToolCall": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"injected": {
"type": "boolean"
},
"input": {
"type": "string"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"provider_response_id": {
"type": "string"
},
"server_url": {
"type": "string"
},
"tool": {
"type": "string"
}
}
},
"codersdk.AIBridgeToolUsage": {
"type": "object",
"properties": {
@@ -17426,6 +17681,10 @@ const docTemplate = `{
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"is_service_account": {
"type": "boolean"
},
@@ -20222,6 +20481,10 @@ const docTemplate = `{
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
@@ -21071,6 +21334,10 @@ const docTemplate = `{
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
+259
View File
@@ -65,6 +65,30 @@
}
}
},
"/aibridge/clients": {
"get": {
"produces": ["application/json"],
"tags": ["AI Bridge"],
"summary": "List AI Bridge clients",
"operationId": "list-ai-bridge-clients",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/aibridge/interceptions": {
"get": {
"produces": ["application/json"],
@@ -183,6 +207,54 @@
]
}
},
"/aibridge/sessions/{session_id}": {
"get": {
"produces": ["application/json"],
"tags": ["AI Bridge"],
"summary": "Get AI Bridge session threads",
"operationId": "get-ai-bridge-session-threads",
"parameters": [
{
"type": "string",
"description": "Session ID (client_session_id or interception UUID)",
"name": "session_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Thread pagination cursor (forward/older)",
"name": "after_id",
"in": "query"
},
{
"type": "string",
"description": "Thread pagination cursor (backward/newer)",
"name": "before_id",
"in": "query"
},
{
"type": "integer",
"description": "Number of threads per page (default 50)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/appearance": {
"get": {
"produces": ["application/json"],
@@ -11261,6 +11333,29 @@
}
}
},
"codersdk.AIBridgeAgenticAction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"thinking": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeModelThought"
}
},
"token_usage": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsTokenUsage"
},
"tool_calls": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeToolCall"
}
}
}
},
"codersdk.AIBridgeAnthropicConfig": {
"type": "object",
"properties": {
@@ -11377,6 +11472,9 @@
"provider": {
"type": "string"
},
"provider_name": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
@@ -11429,6 +11527,14 @@
}
}
},
"codersdk.AIBridgeModelThought": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
}
},
"codersdk.AIBridgeOpenAIConfig": {
"type": "object",
"properties": {
@@ -11528,6 +11634,76 @@
}
}
},
"codersdk.AIBridgeSessionThreadsResponse": {
"type": "object",
"properties": {
"client": {
"type": "string"
},
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string"
},
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"page_ended_at": {
"type": "string",
"format": "date-time"
},
"page_started_at": {
"type": "string",
"format": "date-time"
},
"providers": {
"type": "array",
"items": {
"type": "string"
}
},
"started_at": {
"type": "string",
"format": "date-time"
},
"threads": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeThread"
}
},
"token_usage_summary": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsTokenUsage"
}
}
},
"codersdk.AIBridgeSessionThreadsTokenUsage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"output_tokens": {
"type": "integer"
}
}
},
"codersdk.AIBridgeSessionTokenUsageSummary": {
"type": "object",
"properties": {
@@ -11539,6 +11715,41 @@
}
}
},
"codersdk.AIBridgeThread": {
"type": "object",
"properties": {
"agentic_actions": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeAgenticAction"
}
},
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"model": {
"type": "string"
},
"prompt": {
"type": "string"
},
"provider": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"token_usage": {
"$ref": "#/definitions/codersdk.AIBridgeSessionThreadsTokenUsage"
}
}
},
"codersdk.AIBridgeTokenUsage": {
"type": "object",
"properties": {
@@ -11569,6 +11780,42 @@
}
}
},
"codersdk.AIBridgeToolCall": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"injected": {
"type": "boolean"
},
"input": {
"type": "string"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"provider_response_id": {
"type": "string"
},
"server_url": {
"type": "string"
},
"tool": {
"type": "string"
}
}
},
"codersdk.AIBridgeToolUsage": {
"type": "object",
"properties": {
@@ -15851,6 +16098,10 @@
"$ref": "#/definitions/codersdk.SlimRole"
}
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"is_service_account": {
"type": "boolean"
},
@@ -18547,6 +18798,10 @@
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
@@ -19339,6 +19594,10 @@
"type": "string",
"format": "email"
},
"has_ai_seat": {
"description": "HasAISeat intentionally omits omitempty so the API always includes the\nfield, even when false.",
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
+15
View File
@@ -582,5 +582,20 @@ func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*
Value: sessionToken,
Path: "/",
HttpOnly: true,
// MaxAge is set so the browser persists the cookie to disk rather
// than keeping it in memory as a session cookie. Standalone PWAs
// (display: standalone) run in their own browser process, and
// mobile OSes kill that process when the app is swiped away —
// deleting in-memory cookies and forcing an unexpected login.
//
// We use a long static value (1 year) instead of the key's
// LifetimeSeconds because the server refreshes the key's
// ExpiresAt on activity but does not re-set the cookie. Tying
// MaxAge to the key lifetime would cause the cookie to expire
// client-side even when the server-side key is still valid.
//
// Security is not affected: the server validates ExpiresAt on
// every request regardless of the cookie's MaxAge.
MaxAge: int((365 * 24 * time.Hour).Seconds()),
}), &newkey, nil
}
+49
View File
@@ -394,6 +394,55 @@ func TestSessionExpiry(t *testing.T) {
}
}
// TestSessionCookieMaxAge verifies that the session cookie is a persistent
// cookie (has MaxAge set) rather than a session cookie. Standalone PWAs
// run in their own browser process and mobile OSes purge in-memory
// (session) cookies when that process is killed, so the cookie must be
// persisted to disk.
func TestSessionCookieMaxAge(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
// Create the first user (password-based login).
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "SomeSecurePassword!",
}
_, err := client.CreateFirstUser(ctx, req)
require.NoError(t, err)
// Login via the raw HTTP endpoint so we can inspect the Set-Cookie header.
loginURL, err := client.URL.Parse("/api/v2/users/login")
require.NoError(t, err)
res, err := client.Request(ctx, http.MethodPost, loginURL.String(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
})
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusCreated, res.StatusCode)
oneYear := int((365 * 24 * time.Hour).Seconds())
var found bool
for _, cookie := range res.Cookies() {
if cookie.Name == codersdk.SessionTokenCookie {
// MaxAge should be set to a long value so the browser
// persists the cookie to disk. The server handles real
// expiry via the API key's ExpiresAt field.
require.Equal(t, oneYear, cookie.MaxAge,
"Session cookie MaxAge should be set to 1 year for disk persistence")
found = true
}
}
require.True(t, found, "session cookie should be present in login response")
}
func TestAPIKey_OK(t *testing.T) {
t.Parallel()
+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
}
+2
View File
@@ -1155,6 +1155,7 @@ func New(options *Options) *API {
apiKeyMiddleware,
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentAgents),
)
r.Get("/by-workspace", api.chatsByWorkspace)
r.Get("/", api.listChats)
r.Post("/", api.postChats)
r.Get("/models", api.listChatModels)
@@ -1233,6 +1234,7 @@ func New(options *Options) *API {
r.Get("/git", api.watchChatGit)
})
r.Post("/interrupt", api.interruptChat)
r.Post("/title/regenerate", api.regenerateChatTitle)
r.Get("/diff", api.getChatDiffContents)
r.Route("/queue/{queuedMessage}", func(r chi.Router) {
r.Delete("/", api.deleteChatQueuedMessage)
+383 -9
View File
@@ -999,15 +999,16 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
})
intc := codersdk.AIBridgeInterception{
ID: interception.ID,
Initiator: MinimalUserFromVisibleUser(initiator),
Provider: interception.Provider,
Model: interception.Model,
Metadata: jsonOrEmptyMap(interception.Metadata),
StartedAt: interception.StartedAt,
TokenUsages: sdkTokenUsages,
UserPrompts: sdkUserPrompts,
ToolUsages: sdkToolUsages,
ID: interception.ID,
Initiator: MinimalUserFromVisibleUser(initiator),
Provider: interception.Provider,
ProviderName: interception.ProviderName,
Model: interception.Model,
Metadata: jsonOrEmptyMap(interception.Metadata),
StartedAt: interception.StartedAt,
TokenUsages: sdkTokenUsages,
UserPrompts: sdkUserPrompts,
ToolUsages: sdkToolUsages,
}
if interception.APIKeyID.Valid {
intc.APIKeyID = &interception.APIKeyID.String
@@ -1097,6 +1098,287 @@ func AIBridgeToolUsage(usage database.AIBridgeToolUsage) codersdk.AIBridgeToolUs
}
}
// AIBridgeSessionThreads converts session metadata and thread interceptions
// into the threads response. It groups interceptions into threads, builds
// agentic actions from tool usages and model thoughts, and aggregates
// token usage with metadata.
func AIBridgeSessionThreads(
session database.ListAIBridgeSessionsRow,
interceptions []database.ListAIBridgeSessionThreadsRow,
tokenUsages []database.AIBridgeTokenUsage,
toolUsages []database.AIBridgeToolUsage,
userPrompts []database.AIBridgeUserPrompt,
modelThoughts []database.AIBridgeModelThought,
) codersdk.AIBridgeSessionThreadsResponse {
// Index subresources by interception ID.
tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions))
for _, tu := range tokenUsages {
tokensByInterception[tu.InterceptionID] = append(tokensByInterception[tu.InterceptionID], tu)
}
toolsByInterception := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(interceptions))
for _, tu := range toolUsages {
toolsByInterception[tu.InterceptionID] = append(toolsByInterception[tu.InterceptionID], tu)
}
promptsByInterception := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(interceptions))
for _, up := range userPrompts {
promptsByInterception[up.InterceptionID] = append(promptsByInterception[up.InterceptionID], up)
}
thoughtsByInterception := make(map[uuid.UUID][]database.AIBridgeModelThought, len(interceptions))
for _, mt := range modelThoughts {
thoughtsByInterception[mt.InterceptionID] = append(thoughtsByInterception[mt.InterceptionID], mt)
}
// Group interceptions by thread_id, preserving the order returned by the
// SQL query.
interceptionsByThread := make(map[uuid.UUID][]database.AIBridgeInterception, len(interceptions))
var threadIDs []uuid.UUID
for _, row := range interceptions {
if _, ok := interceptionsByThread[row.ThreadID]; !ok {
threadIDs = append(threadIDs, row.ThreadID)
}
interceptionsByThread[row.ThreadID] = append(interceptionsByThread[row.ThreadID], row.AIBridgeInterception)
}
// Build threads and track page time bounds.
threads := make([]codersdk.AIBridgeThread, 0, len(threadIDs))
var pageStartedAt, pageEndedAt *time.Time
for _, threadID := range threadIDs {
intcs := interceptionsByThread[threadID]
thread := buildAIBridgeThread(threadID, intcs, tokensByInterception, toolsByInterception, promptsByInterception, thoughtsByInterception)
for _, intc := range intcs {
if pageStartedAt == nil || intc.StartedAt.Before(*pageStartedAt) {
t := intc.StartedAt
pageStartedAt = &t
}
if intc.EndedAt.Valid {
if pageEndedAt == nil || intc.EndedAt.Time.After(*pageEndedAt) {
t := intc.EndedAt.Time
pageEndedAt = &t
}
}
}
threads = append(threads, thread)
}
// Aggregate session-level token usage metadata from all token
// usages in the session (not just the page).
sessionTokenMeta := aggregateTokenMetadata(tokenUsages)
resp := codersdk.AIBridgeSessionThreadsResponse{
ID: session.SessionID,
Initiator: MinimalUserFromVisibleUser(database.VisibleUser{
ID: session.UserID,
Username: session.UserUsername,
Name: session.UserName,
AvatarURL: session.UserAvatarUrl,
}),
Providers: session.Providers,
Models: session.Models,
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: session.Metadata, Valid: len(session.Metadata) > 0}),
StartedAt: session.StartedAt,
PageStartedAt: pageStartedAt,
PageEndedAt: pageEndedAt,
TokenUsageSummary: codersdk.AIBridgeSessionThreadsTokenUsage{
InputTokens: session.InputTokens,
OutputTokens: session.OutputTokens,
Metadata: sessionTokenMeta,
},
Threads: threads,
}
if resp.Providers == nil {
resp.Providers = []string{}
}
if resp.Models == nil {
resp.Models = []string{}
}
if session.Client != "" {
resp.Client = &session.Client
}
if !session.EndedAt.IsZero() {
resp.EndedAt = &session.EndedAt
}
return resp
}
func buildAIBridgeThread(
threadID uuid.UUID,
interceptions []database.AIBridgeInterception,
tokensByInterception map[uuid.UUID][]database.AIBridgeTokenUsage,
toolsByInterception map[uuid.UUID][]database.AIBridgeToolUsage,
promptsByInterception map[uuid.UUID][]database.AIBridgeUserPrompt,
thoughtsByInterception map[uuid.UUID][]database.AIBridgeModelThought,
) codersdk.AIBridgeThread {
// Find the root interception (where id == threadID) to get the
// thread prompt and model.
var rootIntc *database.AIBridgeInterception
for i := range interceptions {
if interceptions[i].ID == threadID {
rootIntc = &interceptions[i]
break
}
}
// Fallback to first interception if root not found.
if rootIntc == nil && len(interceptions) > 0 {
rootIntc = &interceptions[0]
}
thread := codersdk.AIBridgeThread{
ID: threadID,
}
if rootIntc != nil {
thread.Model = rootIntc.Model
thread.Provider = rootIntc.Provider
// Get first user prompt from root interception.
// A thread can only have one prompt, by definition, since we currently
// only store the last prompt observed in an interception.
if prompts := promptsByInterception[rootIntc.ID]; len(prompts) > 0 {
thread.Prompt = &prompts[0].Prompt
}
}
// Compute thread time bounds from interceptions.
for _, intc := range interceptions {
if thread.StartedAt.IsZero() || intc.StartedAt.Before(thread.StartedAt) {
thread.StartedAt = intc.StartedAt
}
if intc.EndedAt.Valid {
if thread.EndedAt == nil || intc.EndedAt.Time.After(*thread.EndedAt) {
t := intc.EndedAt.Time
thread.EndedAt = &t
}
}
}
// Build agentic actions grouped by interception. Each interception that
// has tool calls produces one action with all its tool calls, thinking
// blocks, and token usage.
var actions []codersdk.AIBridgeAgenticAction
for _, intc := range interceptions {
tools := toolsByInterception[intc.ID]
if len(tools) == 0 {
continue
}
// Thinking blocks for this interception.
thoughts := thoughtsByInterception[intc.ID]
thinking := make([]codersdk.AIBridgeModelThought, 0, len(thoughts))
for _, mt := range thoughts {
thinking = append(thinking, codersdk.AIBridgeModelThought{
Text: mt.Content,
})
}
// Token usage for the interception.
actionTokenUsage := aggregateTokenUsage(tokensByInterception[intc.ID])
// Build tool call list.
toolCalls := make([]codersdk.AIBridgeToolCall, 0, len(tools))
for _, tu := range tools {
toolCalls = append(toolCalls, codersdk.AIBridgeToolCall{
ID: tu.ID,
InterceptionID: tu.InterceptionID,
ProviderResponseID: tu.ProviderResponseID,
ServerURL: tu.ServerUrl.String,
Tool: tu.Tool,
Injected: tu.Injected,
Input: tu.Input,
Metadata: jsonOrEmptyMap(tu.Metadata),
CreatedAt: tu.CreatedAt,
})
}
actions = append(actions, codersdk.AIBridgeAgenticAction{
Model: intc.Model,
TokenUsage: actionTokenUsage,
Thinking: thinking,
ToolCalls: toolCalls,
})
}
if actions == nil {
// Make an empty slice so we don't serialize `null`.
actions = make([]codersdk.AIBridgeAgenticAction, 0)
}
thread.AgenticActions = actions
// Aggregate thread-level token usage.
var threadTokens []database.AIBridgeTokenUsage
for _, intc := range interceptions {
threadTokens = append(threadTokens, tokensByInterception[intc.ID]...)
}
thread.TokenUsage = aggregateTokenUsage(threadTokens)
return thread
}
// aggregateTokenUsage sums token usage rows and aggregates metadata.
func aggregateTokenUsage(tokens []database.AIBridgeTokenUsage) codersdk.AIBridgeSessionThreadsTokenUsage {
var inputTokens, outputTokens int64
for _, tu := range tokens {
inputTokens += tu.InputTokens
outputTokens += tu.OutputTokens
// TODO: once https://github.com/coder/aibridge/issues/150 lands we
// should aggregate the other token types.
}
return codersdk.AIBridgeSessionThreadsTokenUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
Metadata: aggregateTokenMetadata(tokens),
}
}
// aggregateTokenMetadata sums all numeric values from the metadata
// JSONB across the given token usage rows by key. Nested objects are
// flattened using dot-notation (e.g. {"cache": {"read_tokens": 10}}
// becomes "cache.read_tokens"). Non-numeric leaves (strings,
// booleans, arrays, nulls) are silently skipped.
func aggregateTokenMetadata(tokens []database.AIBridgeTokenUsage) map[string]any {
sums := make(map[string]int64)
for _, tu := range tokens {
if !tu.Metadata.Valid || len(tu.Metadata.RawMessage) == 0 {
continue
}
var m map[string]json.RawMessage
if err := json.Unmarshal(tu.Metadata.RawMessage, &m); err != nil {
continue
}
flattenAndSum(sums, "", m)
}
result := make(map[string]any, len(sums))
for k, v := range sums {
result[k] = v
}
return result
}
// flattenAndSum recursively walks a JSON object and sums all numeric
// leaf values into sums, using dot-separated keys for nested objects.
func flattenAndSum(sums map[string]int64, prefix string, m map[string]json.RawMessage) {
for k, raw := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
// Try as a number first.
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
if v, err := n.Int64(); err == nil {
sums[key] += v
}
continue
}
// Try as a nested object.
var nested map[string]json.RawMessage
if err := json.Unmarshal(raw, &nested); err == nil {
flattenAndSum(sums, key, nested)
}
// Arrays, strings, booleans, nulls are skipped.
}
}
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
var presets []codersdk.InvalidatedPreset
for _, p := range invalidatedPresets {
@@ -1235,6 +1517,98 @@ func nullInt64Ptr(v sql.NullInt64) *int64 {
return &value
}
// Chat converts a database.Chat to a codersdk.Chat. It coalesces
// nil slices and maps to empty values for JSON serialization and
// derives RootChatID from the parent chain when not explicitly set.
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
mcpServerIDs := c.MCPServerIDs
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
}
labels := map[string]string(c.Labels)
if labels == nil {
labels = map[string]string{}
}
chat := codersdk.Chat{
ID: c.ID,
OwnerID: c.OwnerID,
LastModelConfigID: c.LastModelConfigID,
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
Archived: c.Archived,
PinOrder: c.PinOrder,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
MCPServerIDs: mcpServerIDs,
Labels: labels,
}
if c.LastError.Valid {
chat.LastError = &c.LastError.String
}
if c.ParentChatID.Valid {
parentChatID := c.ParentChatID.UUID
chat.ParentChatID = &parentChatID
}
switch {
case c.RootChatID.Valid:
rootChatID := c.RootChatID.UUID
chat.RootChatID = &rootChatID
case c.ParentChatID.Valid:
rootChatID := c.ParentChatID.UUID
chat.RootChatID = &rootChatID
default:
rootChatID := c.ID
chat.RootChatID = &rootChatID
}
if c.WorkspaceID.Valid {
chat.WorkspaceID = &c.WorkspaceID.UUID
}
if c.BuildID.Valid {
chat.BuildID = &c.BuildID.UUID
}
if c.AgentID.Valid {
chat.AgentID = &c.AgentID.UUID
}
if diffStatus != nil {
convertedDiffStatus := ChatDiffStatus(c.ID, diffStatus)
chat.DiffStatus = &convertedDiffStatus
}
if c.LastInjectedContext.Valid {
var parts []codersdk.ChatMessagePart
// Internal fields are stripped at write time in
// chatd.updateLastInjectedContext, so no
// StripInternal call is needed here. Unmarshal
// errors are suppressed — the column is written by
// us with a known schema.
if err := json.Unmarshal(c.LastInjectedContext.RawMessage, &parts); err == nil {
chat.LastInjectedContext = parts
}
}
return chat
}
// ChatRows converts a slice of database.GetChatsRow (which embeds
// Chat plus HasUnread) to codersdk.Chat, looking up diff statuses
// from the provided map. When diffStatusesByChatID is non-nil,
// chats without an entry receive an empty DiffStatus.
func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
result := make([]codersdk.Chat, len(rows))
for i, row := range rows {
diffStatus, ok := diffStatusesByChatID[row.Chat.ID]
if ok {
result[i] = Chat(row.Chat, &diffStatus)
} else {
result[i] = Chat(row.Chat, nil)
if diffStatusesByChatID != nil {
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
}
}
result[i].HasUnread = row.HasUnread
}
return result
}
// ChatDiffStatus converts a database.ChatDiffStatus to a
// codersdk.ChatDiffStatus. When status is nil an empty value
// containing only the chatID is returned.
@@ -0,0 +1,308 @@
package db2sdk
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
)
func TestAggregateTokenMetadata(t *testing.T) {
t.Parallel()
t.Run("empty_input", func(t *testing.T) {
t.Parallel()
result := aggregateTokenMetadata(nil)
require.Empty(t, result)
})
t.Run("sums_across_rows", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache_read_tokens":100,"reasoning_tokens":50}`),
Valid: true,
},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache_read_tokens":200,"reasoning_tokens":75}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(300), result["cache_read_tokens"])
require.Equal(t, int64(125), result["reasoning_tokens"])
require.Len(t, result, 2)
})
t.Run("skips_null_and_invalid_metadata", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{Valid: false},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: nil,
Valid: true,
},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"tokens":42}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(42), result["tokens"])
require.Len(t, result, 1)
})
t.Run("skips_non_integer_values", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
// Float values fail json.Number.Int64(), so they
// are silently dropped.
RawMessage: json.RawMessage(`{"good":10,"fractional":1.5}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(10), result["good"])
_, hasFractional := result["fractional"]
require.False(t, hasFractional)
})
t.Run("skips_malformed_json", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`not json`),
Valid: true,
},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"tokens":5}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
// The malformed row is skipped, the valid one is counted.
require.Equal(t, int64(5), result["tokens"])
require.Len(t, result, 1)
})
t.Run("flattens_nested_objects", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_read_tokens": 100,
"cache": {"creation_tokens": 40, "read_tokens": 60},
"reasoning_tokens": 50,
"tags": ["a", "b"]
}`),
Valid: true,
},
},
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_read_tokens": 200,
"cache": {"creation_tokens": 10}
}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(300), result["cache_read_tokens"])
require.Equal(t, int64(50), result["reasoning_tokens"])
require.Equal(t, int64(50), result["cache.creation_tokens"])
require.Equal(t, int64(60), result["cache.read_tokens"])
// Arrays are skipped.
_, hasTags := result["tags"]
require.False(t, hasTags)
require.Len(t, result, 4)
})
t.Run("flattens_deeply_nested_objects", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"provider": {
"anthropic": {"cache_creation_tokens": 100, "cache_read_tokens": 200},
"openai": {"reasoning_tokens": 50}
},
"total": 500
}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(100), result["provider.anthropic.cache_creation_tokens"])
require.Equal(t, int64(200), result["provider.anthropic.cache_read_tokens"])
require.Equal(t, int64(50), result["provider.openai.reasoning_tokens"])
require.Equal(t, int64(500), result["total"])
require.Len(t, result, 4)
})
// Real-world provider metadata shapes from
// https://github.com/coder/aibridge/issues/150.
t.Run("aggregates_real_provider_metadata", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
// Anthropic-style: cache fields are top-level.
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 23490
}`),
Valid: true,
},
},
{
// OpenAI-style: cache fields are nested inside
// input_tokens_details.
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"input_tokens_details": {"cached_tokens": 11904}
}`),
Valid: true,
},
},
{
// Second Anthropic row to verify summing.
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{
"cache_creation_input_tokens": 500,
"cache_read_input_tokens": 10000
}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
// Anthropic fields are summed across two rows.
require.Equal(t, int64(500), result["cache_creation_input_tokens"])
require.Equal(t, int64(33490), result["cache_read_input_tokens"])
// OpenAI nested field is flattened with dot notation.
require.Equal(t, int64(11904), result["input_tokens_details.cached_tokens"])
require.Len(t, result, 3)
})
t.Run("skips_string_boolean_null_values", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"tokens":10,"name":"test","enabled":true,"nothing":null}`),
Valid: true,
},
},
}
result := aggregateTokenMetadata(tokens)
require.Equal(t, int64(10), result["tokens"])
require.Len(t, result, 1)
})
}
func TestAggregateTokenUsage(t *testing.T) {
t.Parallel()
t.Run("empty_input", func(t *testing.T) {
t.Parallel()
result := aggregateTokenUsage(nil)
require.Equal(t, int64(0), result.InputTokens)
require.Equal(t, int64(0), result.OutputTokens)
require.Empty(t, result.Metadata)
})
t.Run("sums_tokens_and_metadata", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
InputTokens: 100,
OutputTokens: 50,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"reasoning_tokens":20}`),
Valid: true,
},
},
{
ID: uuid.New(),
InputTokens: 200,
OutputTokens: 75,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"reasoning_tokens":30}`),
Valid: true,
},
},
}
result := aggregateTokenUsage(tokens)
require.Equal(t, int64(300), result.InputTokens)
require.Equal(t, int64(125), result.OutputTokens)
require.Equal(t, int64(50), result.Metadata["reasoning_tokens"])
})
t.Run("handles_rows_without_metadata", func(t *testing.T) {
t.Parallel()
tokens := []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
InputTokens: 500,
OutputTokens: 200,
Metadata: pqtype.NullRawMessage{Valid: false},
},
}
result := aggregateTokenUsage(tokens)
require.Equal(t, int64(500), result.InputTokens)
require.Equal(t, int64(200), result.OutputTokens)
require.Empty(t, result.Metadata)
})
}
+64
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"reflect"
"testing"
"time"
@@ -513,6 +514,69 @@ func TestChatQueuedMessage_ParsesUserContentParts(t *testing.T) {
require.Equal(t, "queued text", queued.Content[0].Text)
}
func TestChat_AllFieldsPopulated(t *testing.T) {
t.Parallel()
// Every field of database.Chat is set to a non-zero value so
// that the reflection check below catches any field that
// db2sdk.Chat forgets to populate. When someone adds a new
// field to codersdk.Chat, this test will fail until the
// converter is updated.
now := dbtime.Now()
input := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
BuildID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
RootChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
LastModelConfigID: uuid.New(),
Title: "all-fields-test",
Status: database.ChatStatusRunning,
LastError: sql.NullString{String: "boom", Valid: true},
CreatedAt: now,
UpdatedAt: now,
Archived: true,
PinOrder: 1,
MCPServerIDs: []uuid.UUID{uuid.New()},
Labels: database.StringMap{"env": "prod"},
LastInjectedContext: pqtype.NullRawMessage{
// Use a context-file part to verify internal
// fields are not present (they are stripped at
// write time by chatd, not at read time).
RawMessage: json.RawMessage(`[{"type":"context-file","context_file_path":"/AGENTS.md"}]`),
Valid: true,
},
}
// Only ChatID is needed here. This test checks that
// Chat.DiffStatus is non-nil, not that every DiffStatus
// field is populated — that would be a separate test for
// the ChatDiffStatus converter.
diffStatus := &database.ChatDiffStatus{
ChatID: input.ID,
}
got := db2sdk.Chat(input, diffStatus)
v := reflect.ValueOf(got)
typ := v.Type()
// HasUnread is populated by ChatRows (which joins the
// read-cursor query), not by Chat, so it is expected
// to remain zero here.
skip := map[string]bool{"HasUnread": true}
for i := range typ.NumField() {
field := typ.Field(i)
if skip[field.Name] {
continue
}
require.False(t, v.Field(i).IsZero(),
"codersdk.Chat field %q is zero-valued — db2sdk.Chat may not be populating it",
field.Name,
)
}
}
func TestChatQueuedMessage_MalformedContent(t *testing.T) {
t.Parallel()
+193 -19
View File
@@ -1570,13 +1570,13 @@ func (q *querier) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UU
return q.db.AllUserIDs(ctx, includeSystem)
}
func (q *querier) ArchiveChatByID(ctx context.Context, id uuid.UUID) error {
func (q *querier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
return err
return nil, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
return nil, err
}
return q.db.ArchiveChatByID(ctx, id)
}
@@ -2578,6 +2578,18 @@ func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]dat
return files, nil
}
func (q *querier) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) {
// The include-default-system-prompt flag is a deployment-wide setting read
// during chat creation by every authenticated user, so no RBAC policy
// check is needed. We still verify that a valid actor exists in the
// context to ensure this is never callable by an unauthenticated or
// system-internal path without an explicit actor.
if _, ok := ActorFromContext(ctx); !ok {
return false, ErrNoActor
}
return q.db.GetChatIncludeDefaultSystemPrompt(ctx)
}
func (q *querier) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) {
// ChatMessages are authorized through their parent Chat.
// We need to fetch the message first to get its chat_id.
@@ -2602,6 +2614,14 @@ func (q *querier) GetChatMessagesByChatID(ctx context.Context, arg database.GetC
return q.db.GetChatMessagesByChatID(ctx, arg)
}
func (q *querier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, arg database.GetChatMessagesByChatIDAscPaginatedParams) ([]database.ChatMessage, error) {
_, err := q.GetChatByID(ctx, arg.ChatID)
if err != nil {
return nil, err
}
return q.db.GetChatMessagesByChatIDAscPaginated(ctx, arg)
}
func (q *querier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, arg database.GetChatMessagesByChatIDDescPaginatedParams) ([]database.ChatMessage, error) {
_, err := q.GetChatByID(ctx, arg.ChatID)
if err != nil {
@@ -2674,6 +2694,18 @@ func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) {
return q.db.GetChatSystemPrompt(ctx)
}
func (q *querier) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) {
// The system prompt configuration is a deployment-wide setting read during
// chat creation by every authenticated user, so no RBAC policy check is
// needed. We still verify that a valid actor exists in the context to
// ensure this is never callable by an unauthenticated or system-internal
// path without an explicit actor.
if _, ok := ActorFromContext(ctx); !ok {
return database.GetChatSystemPromptConfigRow{}, ErrNoActor
}
return q.db.GetChatSystemPromptConfig(ctx)
}
// GetChatTemplateAllowlist requires deployment-config read permission,
// unlike the peer getters (GetChatDesktopEnabled, etc.) which only
// check actor presence. The allowlist is admin-configuration that
@@ -2716,7 +2748,7 @@ func (q *querier) GetChatWorkspaceTTL(ctx context.Context) (string, error) {
return q.db.GetChatWorkspaceTTL(ctx)
}
func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
@@ -2724,6 +2756,10 @@ func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]
return q.db.GetAuthorizedChats(ctx, arg, prep)
}
func (q *querier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByWorkspaceIDs)(ctx, ids)
}
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
// Just like with the audit logs query, shortcut if the user is an owner.
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
@@ -2775,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)
@@ -3921,6 +3965,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
return q.db.GetUnexpiredLicenses(ctx)
}
func (q *querier) GetUserAISeatStates(ctx context.Context, userIDs []uuid.UUID) ([]uuid.UUID, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return nil, err
}
return q.db.GetUserAISeatStates(ctx, userIDs)
}
func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
// Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms.
if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate); err != nil {
@@ -5313,6 +5364,14 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab
return q.db.InsertWorkspaceResourceMetadata(ctx, arg)
}
func (q *querier) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
return q.db.ListAuthorizedAIBridgeClients(ctx, arg, prep)
}
func (q *querier) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
@@ -5328,6 +5387,13 @@ func (q *querier) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Contex
return q.db.ListAIBridgeInterceptionsTelemetrySummaries(ctx, arg)
}
func (q *querier) ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIDs []uuid.UUID) ([]database.AIBridgeModelThought, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil {
return nil, err
}
return q.db.ListAIBridgeModelThoughtsByInterceptionIDs(ctx, interceptionIDs)
}
func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams) ([]string, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
@@ -5336,6 +5402,14 @@ func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBri
return q.db.ListAuthorizedAIBridgeModels(ctx, arg, prep)
}
func (q *querier) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
return q.db.ListAuthorizedAIBridgeSessionThreads(ctx, arg, prep)
}
func (q *querier) ListAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams) ([]database.ListAIBridgeSessionsRow, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
@@ -5473,6 +5547,17 @@ func (q *querier) PaginatedOrganizationMembers(ctx context.Context, arg database
return q.db.PaginatedOrganizationMembers(ctx, arg)
}
func (q *querier) PinChatByID(ctx context.Context, id uuid.UUID) error {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.PinChatByID(ctx, id)
}
func (q *querier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) {
chat, err := q.db.GetChatByID(ctx, chatID)
if err != nil {
@@ -5564,13 +5649,13 @@ func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) {
return q.db.TryAcquireLock(ctx, id)
}
func (q *querier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error {
func (q *querier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
return err
return nil, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
return nil, err
}
return q.db.UnarchiveChatByID(ctx, id)
}
@@ -5598,6 +5683,17 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id)
}
func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.UnpinChatByID(ctx, id)
}
func (q *querier) UnsetDefaultChatModelConfigs(ctx context.Context) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
@@ -5619,6 +5715,18 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe
return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg)
}
func (q *querier) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatBuildAgentBinding(ctx, arg)
}
func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
@@ -5652,6 +5760,39 @@ func (q *querier) UpdateChatLabelsByID(ctx context.Context, arg database.UpdateC
return q.db.UpdateChatLabelsByID(ctx, arg)
}
func (q *querier) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatLastInjectedContext(ctx, arg)
}
func (q *querier) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatLastModelConfigByID(ctx, arg)
}
func (q *querier) UpdateChatLastReadMessageID(ctx context.Context, arg database.UpdateChatLastReadMessageIDParams) error {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.UpdateChatLastReadMessageID(ctx, arg)
}
func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
@@ -5686,6 +5827,17 @@ func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.Update
return q.db.UpdateChatModelConfig(ctx, arg)
}
func (q *querier) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.UpdateChatPinOrder(ctx, arg)
}
func (q *querier) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return database.ChatProvider{}, err
@@ -5706,7 +5858,18 @@ func (q *querier) UpdateChatStatus(ctx context.Context, arg database.UpdateChatS
return q.db.UpdateChatStatus(ctx, arg)
}
func (q *querier) UpdateChatWorkspace(ctx context.Context, arg database.UpdateChatWorkspaceParams) (database.Chat, error) {
func (q *querier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg database.UpdateChatStatusPreserveUpdatedAtParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatStatusPreserveUpdatedAt(ctx, arg)
}
func (q *querier) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
@@ -5715,15 +5878,7 @@ func (q *querier) UpdateChatWorkspace(ctx context.Context, arg database.UpdateCh
return database.Chat{}, err
}
// UpdateChatWorkspace is manually implemented for chat tables and may not be
// present on every wrapped store interface yet.
chatWorkspaceUpdater, ok := q.db.(interface {
UpdateChatWorkspace(context.Context, database.UpdateChatWorkspaceParams) (database.Chat, error)
})
if !ok {
return database.Chat{}, xerrors.New("update chat workspace is not implemented by wrapped store")
}
return chatWorkspaceUpdater.UpdateChatWorkspace(ctx, arg)
return q.db.UpdateChatWorkspaceBinding(ctx, arg)
}
func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) {
@@ -6827,6 +6982,13 @@ func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg databas
return q.db.UpsertChatDiffStatusReference(ctx, arg)
}
func (q *querier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt)
}
func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
@@ -7167,6 +7329,14 @@ func (q *querier) ListAuthorizedAIBridgeModels(ctx context.Context, arg database
return q.ListAIBridgeModels(ctx, arg)
}
func (q *querier) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, _ rbac.PreparedAuthorized) ([]string, error) {
// TODO: Delete this function, all ListAIBridgeClients should be
// authorized. For now just call ListAIBridgeClients on the authz
// querier. This cannot be deleted for now because it's included in
// the database.Store interface, so dbauthz needs to implement it.
return q.ListAIBridgeClients(ctx, arg)
}
func (q *querier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionsRow, error) {
return q.db.ListAuthorizedAIBridgeSessions(ctx, arg, prepared)
}
@@ -7175,6 +7345,10 @@ func (q *querier) CountAuthorizedAIBridgeSessions(ctx context.Context, arg datab
return q.db.CountAuthorizedAIBridgeSessions(ctx, arg, prepared)
}
func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.Chat, error) {
func (q *querier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionThreadsRow, error) {
return q.db.ListAuthorizedAIBridgeSessionThreads(ctx, arg, prepared)
}
func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
return q.GetChats(ctx, arg)
}
+165 -12
View File
@@ -392,13 +392,25 @@ func (s *MethodTestSuite) TestChats() {
s.Run("ArchiveChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().ArchiveChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns()
dbm.EXPECT().ArchiveChatByID(gomock.Any(), chat.ID).Return([]database.Chat{chat}, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns([]database.Chat{chat})
}))
s.Run("UnarchiveChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes()
dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return([]database.Chat{chat}, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns([]database.Chat{chat})
}))
s.Run("PinChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().PinChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("UnpinChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UnpinChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("SoftDeleteChatMessagesAfterID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -449,6 +461,13 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatByIDForUpdate(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(chat)
}))
s.Run("GetChatsByWorkspaceIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chatA := testutil.Fake(s.T(), faker, database.Chat{})
chatB := testutil.Fake(s.T(), faker, database.Chat{})
arg := []uuid.UUID{chatA.WorkspaceID.UUID, chatB.WorkspaceID.UUID}
dbm.EXPECT().GetChatsByWorkspaceIDs(gomock.Any(), arg).Return([]database.Chat{chatA, chatB}, nil).AnyTimes()
check.Args(arg).Asserts(chatA, policy.ActionRead, chatB, policy.ActionRead).Returns([]database.Chat{chatA, chatB})
}))
s.Run("GetChatCostPerChat", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetChatCostPerChatParams{
OwnerID: uuid.New(),
@@ -573,6 +592,14 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatMessagesByChatID(gomock.Any(), arg).Return(msgs, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionRead).Returns(msgs)
}))
s.Run("GetChatMessagesByChatIDAscPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
msgs := []database.ChatMessage{testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})}
arg := database.GetChatMessagesByChatIDAscPaginatedParams{ChatID: chat.ID, AfterID: 0, LimitVal: 50}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().GetChatMessagesByChatIDAscPaginated(gomock.Any(), arg).Return(msgs, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionRead).Returns(msgs)
}))
s.Run("GetChatMessagesByChatIDDescPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
msgs := []database.ChatMessage{testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})}
@@ -604,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{})
@@ -631,13 +658,13 @@ func (s *MethodTestSuite) TestChats() {
}))
s.Run("GetChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatsParams{}
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes()
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("GetAuthorizedChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatsParams{}
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes()
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes()
// No asserts here because it re-routes through GetChats which uses SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
@@ -648,6 +675,17 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatQueuedMessages(gomock.Any(), chat.ID).Return(qms, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(qms)
}))
s.Run("GetChatIncludeDefaultSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatIncludeDefaultSystemPrompt(gomock.Any()).Return(true, nil).AnyTimes()
check.Args().Asserts()
}))
s.Run("GetChatSystemPromptConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatSystemPromptConfig(gomock.Any()).Return(database.GetChatSystemPromptConfigRow{
ChatSystemPrompt: "prompt",
IncludeDefaultSystemPrompt: true,
}, nil).AnyTimes()
check.Args().Asserts()
}))
s.Run("GetChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatSystemPrompt(gomock.Any()).Return("prompt", nil).AnyTimes()
check.Args().Asserts()
@@ -683,7 +721,9 @@ func (s *MethodTestSuite) TestChats() {
check.Args(threshold).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(chats)
}))
s.Run("InsertChat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := testutil.Fake(s.T(), faker, database.InsertChatParams{})
arg := testutil.Fake(s.T(), faker, database.InsertChatParams{
Status: database.ChatStatusWaiting,
})
chat := testutil.Fake(s.T(), faker, database.Chat{OwnerID: arg.OwnerID})
dbm.EXPECT().InsertChat(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()), policy.ActionCreate).Returns(chat)
@@ -759,6 +799,26 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatLabelsByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLastModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastModelConfigByIDParams{
ID: chat.ID,
LastModelConfigID: uuid.New(),
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatLastModelConfigByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatStatusPreserveUpdatedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatStatusPreserveUpdatedAtParams{
ID: chat.ID,
Status: database.ChatStatusRunning,
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatStatusPreserveUpdatedAt(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatHeartbeat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatHeartbeatParams{
@@ -809,6 +869,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatProvider(gomock.Any(), arg).Return(provider, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(provider)
}))
s.Run("UpdateChatPinOrder", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatPinOrderParams{
ID: chat.ID,
PinOrder: 2,
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatPinOrder(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("UpdateChatStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatStatusParams{
@@ -819,15 +889,29 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatStatus(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
s.Run("UpdateChatBuildAgentBinding", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatWorkspaceParams{
ID: chat.ID,
WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
arg := database.UpdateChatBuildAgentBindingParams{
ID: chat.ID,
BuildID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
}
updatedChat := testutil.Fake(s.T(), faker, database.Chat{ID: chat.ID})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatWorkspace(gomock.Any(), arg).Return(updatedChat, nil).AnyTimes()
dbm.EXPECT().UpdateChatBuildAgentBinding(gomock.Any(), arg).Return(updatedChat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(updatedChat)
}))
s.Run("UpdateChatWorkspaceBinding", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatWorkspaceBindingParams{
ID: chat.ID,
WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
BuildID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
}
updatedChat := testutil.Fake(s.T(), faker, database.Chat{ID: chat.ID})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatWorkspaceBinding(gomock.Any(), arg).Return(updatedChat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(updatedChat)
}))
s.Run("UnsetDefaultChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
@@ -879,6 +963,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().BackoffChatDiffStatus(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns()
}))
s.Run("UpsertChatIncludeDefaultSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatIncludeDefaultSystemPrompt(gomock.Any(), false).Return(nil).AnyTimes()
check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatSystemPrompt(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
@@ -1118,6 +1206,29 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatMCPServerIDs(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLastInjectedContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastInjectedContextParams{
ID: chat.ID,
LastInjectedContext: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`[{"type":"text","text":"test"}]`),
Valid: true,
},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLastReadMessageID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastReadMessageIDParams{
ID: chat.ID,
LastReadMessageID: 42,
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatLastReadMessageID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("UpdateMCPServerConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.MCPServerConfig{})
arg := database.UpdateMCPServerConfigParams{
@@ -2159,6 +2270,14 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().GetQuotaConsumedForUser(gomock.Any(), arg).Return(int64(0), nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionRead).Returns(int64(0))
}))
s.Run("GetUserAISeatStates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
a := testutil.Fake(s.T(), faker, database.User{})
b := testutil.Fake(s.T(), faker, database.User{})
ids := []uuid.UUID{a.ID, b.ID}
seatStates := []uuid.UUID{a.ID}
dbm.EXPECT().GetUserAISeatStates(gomock.Any(), ids).Return(seatStates, nil).AnyTimes()
check.Args(ids).Asserts(rbac.ResourceUser, policy.ActionRead).Returns(seatStates)
}))
s.Run("GetUserByEmailOrUsername", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.GetUserByEmailOrUsernameParams{Email: u.Email}
@@ -5438,6 +5557,20 @@ func (s *MethodTestSuite) TestAIBridge() {
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("ListAIBridgeClients", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeClientsParams{}
db.EXPECT().ListAuthorizedAIBridgeClients(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("ListAuthorizedAIBridgeClients", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeClientsParams{}
db.EXPECT().ListAuthorizedAIBridgeClients(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("ListAIBridgeSessions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeSessionsParams{}
db.EXPECT().ListAuthorizedAIBridgeSessions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeSessionsRow{}, nil).AnyTimes()
@@ -5484,6 +5617,26 @@ func (s *MethodTestSuite) TestAIBridge() {
check.Args(ids).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.AIBridgeToolUsage{})
}))
s.Run("ListAIBridgeModelThoughtsByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ids := []uuid.UUID{{1}}
db.EXPECT().ListAIBridgeModelThoughtsByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeModelThought{}, nil).AnyTimes()
check.Args(ids).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.AIBridgeModelThought{})
}))
s.Run("ListAIBridgeSessionThreads", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeSessionThreadsParams{}
db.EXPECT().ListAuthorizedAIBridgeSessionThreads(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeSessionThreadsRow{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("ListAuthorizedAIBridgeSessionThreads", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeSessionThreadsParams{}
db.EXPECT().ListAuthorizedAIBridgeSessionThreads(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeSessionThreadsRow{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("UpdateAIBridgeInterceptionEnded", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
intcID := uuid.UUID{1}
params := database.UpdateAIBridgeInterceptionEndedParams{ID: intcID}
+12
View File
@@ -1591,6 +1591,7 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
APIKeyID: seed.APIKeyID,
InitiatorID: takeFirst(seed.InitiatorID, uuid.New()),
Provider: takeFirst(seed.Provider, "provider"),
ProviderName: takeFirst(seed.ProviderName, "provider-name"),
Model: takeFirst(seed.Model, "model"),
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
@@ -1663,6 +1664,17 @@ func AIBridgeToolUsage(t testing.TB, db database.Store, seed database.InsertAIBr
return toolUsage
}
func AIBridgeModelThought(t testing.TB, db database.Store, seed database.InsertAIBridgeModelThoughtParams) database.AIBridgeModelThought {
thought, err := db.InsertAIBridgeModelThought(genCtx, database.InsertAIBridgeModelThoughtParams{
InterceptionID: takeFirst(seed.InterceptionID, uuid.New()),
Content: takeFirst(seed.Content, ""),
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
})
require.NoError(t, err, "insert aibridge model thought")
return thought
}
func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Task {
t.Helper()
+164 -12
View File
@@ -160,12 +160,12 @@ func (m queryMetricsStore) AllUserIDs(ctx context.Context, includeSystem bool) (
return r0, r1
}
func (m queryMetricsStore) ArchiveChatByID(ctx context.Context, id uuid.UUID) error {
func (m queryMetricsStore) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
start := time.Now()
r0 := m.s.ArchiveChatByID(ctx, id)
r0, r1 := m.s.ArchiveChatByID(ctx, id)
m.queryLatencies.WithLabelValues("ArchiveChatByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ArchiveChatByID").Inc()
return r0
return r0, r1
}
func (m queryMetricsStore) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) {
@@ -1120,6 +1120,14 @@ func (m queryMetricsStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUI
return r0, r1
}
func (m queryMetricsStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetChatIncludeDefaultSystemPrompt(ctx)
m.queryLatencies.WithLabelValues("GetChatIncludeDefaultSystemPrompt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatIncludeDefaultSystemPrompt").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) {
start := time.Now()
r0, r1 := m.s.GetChatMessageByID(ctx, id)
@@ -1136,6 +1144,14 @@ func (m queryMetricsStore) GetChatMessagesByChatID(ctx context.Context, chatID d
return r0, r1
}
func (m queryMetricsStore) GetChatMessagesByChatIDAscPaginated(ctx context.Context, arg database.GetChatMessagesByChatIDAscPaginatedParams) ([]database.ChatMessage, error) {
start := time.Now()
r0, r1 := m.s.GetChatMessagesByChatIDAscPaginated(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatMessagesByChatIDAscPaginated").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatMessagesByChatIDAscPaginated").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatMessagesByChatIDDescPaginated(ctx context.Context, arg database.GetChatMessagesByChatIDDescPaginatedParams) ([]database.ChatMessage, error) {
start := time.Now()
r0, r1 := m.s.GetChatMessagesByChatIDDescPaginated(ctx, arg)
@@ -1208,6 +1224,14 @@ func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, err
return r0, r1
}
func (m queryMetricsStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatSystemPromptConfig(ctx)
m.queryLatencies.WithLabelValues("GetChatSystemPromptConfig").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSystemPromptConfig").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatTemplateAllowlist(ctx)
@@ -1248,7 +1272,7 @@ func (m queryMetricsStore) GetChatWorkspaceTTL(ctx context.Context) (string, err
return r0, r1
}
func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) {
start := time.Now()
r0, r1 := m.s.GetChats(ctx, arg)
m.queryLatencies.WithLabelValues("GetChats").Observe(time.Since(start).Seconds())
@@ -1256,6 +1280,14 @@ func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsPa
return r0, r1
}
func (m queryMetricsStore) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetChatsByWorkspaceIDs(ctx, ids)
m.queryLatencies.WithLabelValues("GetChatsByWorkspaceIDs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatsByWorkspaceIDs").Inc()
return r0, r1
}
func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
start := time.Now()
r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg)
@@ -2448,6 +2480,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database
return r0, r1
}
func (m queryMetricsStore) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) {
start := time.Now()
r0, r1 := m.s.GetUserAISeatStates(ctx, userIds)
m.queryLatencies.WithLabelValues("GetUserAISeatStates").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAISeatStates").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserActivityInsights(ctx, arg)
@@ -3712,6 +3752,14 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeClients(ctx, arg)
m.queryLatencies.WithLabelValues("ListAIBridgeClients").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeClients").Inc()
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeInterceptions(ctx, arg)
@@ -3728,6 +3776,14 @@ func (m queryMetricsStore) ListAIBridgeInterceptionsTelemetrySummaries(ctx conte
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]database.AIBridgeModelThought, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeModelThoughtsByInterceptionIDs(ctx, interceptionIds)
m.queryLatencies.WithLabelValues("ListAIBridgeModelThoughtsByInterceptionIDs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeModelThoughtsByInterceptionIDs").Inc()
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams) ([]string, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeModels(ctx, arg)
@@ -3736,6 +3792,14 @@ func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database.
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeSessionThreads(ctx, arg)
m.queryLatencies.WithLabelValues("ListAIBridgeSessionThreads").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeSessionThreads").Inc()
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams) ([]database.ListAIBridgeSessionsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeSessions(ctx, arg)
@@ -3872,6 +3936,14 @@ func (m queryMetricsStore) PaginatedOrganizationMembers(ctx context.Context, arg
return r0, r1
}
func (m queryMetricsStore) PinChatByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.PinChatByID(ctx, id)
m.queryLatencies.WithLabelValues("PinChatByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PinChatByID").Inc()
return r0
}
func (m queryMetricsStore) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (database.ChatQueuedMessage, error) {
start := time.Now()
r0, r1 := m.s.PopNextQueuedMessage(ctx, chatID)
@@ -3952,12 +4024,12 @@ func (m queryMetricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXact
return r0, r1
}
func (m queryMetricsStore) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error {
func (m queryMetricsStore) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
start := time.Now()
r0 := m.s.UnarchiveChatByID(ctx, id)
r0, r1 := m.s.UnarchiveChatByID(ctx, id)
m.queryLatencies.WithLabelValues("UnarchiveChatByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnarchiveChatByID").Inc()
return r0
return r0, r1
}
func (m queryMetricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error {
@@ -3976,6 +4048,14 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID
return r0
}
func (m queryMetricsStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.UnpinChatByID(ctx, id)
m.queryLatencies.WithLabelValues("UnpinChatByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnpinChatByID").Inc()
return r0
}
func (m queryMetricsStore) UnsetDefaultChatModelConfigs(ctx context.Context) error {
start := time.Now()
r0 := m.s.UnsetDefaultChatModelConfigs(ctx)
@@ -4000,6 +4080,14 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up
return r0
}
func (m queryMetricsStore) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatBuildAgentBinding(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatBuildAgentBinding").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatBuildAgentBinding").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatByID(ctx, arg)
@@ -4024,6 +4112,30 @@ func (m queryMetricsStore) UpdateChatLabelsByID(ctx context.Context, arg databas
return r0, r1
}
func (m queryMetricsStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatLastInjectedContext(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatLastInjectedContext").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastInjectedContext").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatLastModelConfigByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatLastModelConfigByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastModelConfigByID").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatLastReadMessageID(ctx context.Context, arg database.UpdateChatLastReadMessageIDParams) error {
start := time.Now()
r0 := m.s.UpdateChatLastReadMessageID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatLastReadMessageID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastReadMessageID").Inc()
return r0
}
func (m queryMetricsStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatMCPServerIDs(ctx, arg)
@@ -4048,6 +4160,14 @@ func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg databa
return r0, r1
}
func (m queryMetricsStore) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error {
start := time.Now()
r0 := m.s.UpdateChatPinOrder(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatPinOrder").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatPinOrder").Inc()
return r0
}
func (m queryMetricsStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatProvider(ctx, arg)
@@ -4064,11 +4184,19 @@ func (m queryMetricsStore) UpdateChatStatus(ctx context.Context, arg database.Up
return r0, r1
}
func (m queryMetricsStore) UpdateChatWorkspace(ctx context.Context, arg database.UpdateChatWorkspaceParams) (database.Chat, error) {
func (m queryMetricsStore) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg database.UpdateChatStatusPreserveUpdatedAtParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatWorkspace(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatWorkspace").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatWorkspace").Inc()
r0, r1 := m.s.UpdateChatStatusPreserveUpdatedAt(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatStatusPreserveUpdatedAt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatStatusPreserveUpdatedAt").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatWorkspaceBinding(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatWorkspaceBinding").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatWorkspaceBinding").Inc()
return r0, r1
}
@@ -4816,6 +4944,14 @@ func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error {
start := time.Now()
r0 := m.s.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt)
m.queryLatencies.WithLabelValues("UpsertChatIncludeDefaultSystemPrompt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatIncludeDefaultSystemPrompt").Inc()
return r0
}
func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertChatSystemPrompt(ctx, value)
@@ -5176,6 +5312,14 @@ func (m queryMetricsStore) ListAuthorizedAIBridgeModels(ctx context.Context, arg
return r0, r1
}
func (m queryMetricsStore) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) {
start := time.Now()
r0, r1 := m.s.ListAuthorizedAIBridgeClients(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeClients").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAuthorizedAIBridgeClients").Inc()
return r0, r1
}
func (m queryMetricsStore) ListAuthorizedAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAuthorizedAIBridgeSessions(ctx, arg, prepared)
@@ -5192,7 +5336,15 @@ func (m queryMetricsStore) CountAuthorizedAIBridgeSessions(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.Chat, error) {
func (m queryMetricsStore) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionThreadsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAuthorizedAIBridgeSessionThreads(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeSessionThreads").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAuthorizedAIBridgeSessionThreads").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
start := time.Now()
r0, r1 := m.s.GetAuthorizedChats(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("GetAuthorizedChats").Observe(time.Since(start).Seconds())
+298 -16
View File
@@ -148,11 +148,12 @@ func (mr *MockStoreMockRecorder) AllUserIDs(ctx, includeSystem any) *gomock.Call
}
// ArchiveChatByID mocks base method.
func (m *MockStore) ArchiveChatByID(ctx context.Context, id uuid.UUID) error {
func (m *MockStore) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ArchiveChatByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].([]database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ArchiveChatByID indicates an expected call of ArchiveChatByID.
@@ -1804,10 +1805,10 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared
}
// GetAuthorizedChats mocks base method.
func (m *MockStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.Chat, error) {
func (m *MockStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthorizedChats", ctx, arg, prepared)
ret0, _ := ret[0].([]database.Chat)
ret0, _ := ret[0].([]database.GetChatsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -2058,6 +2059,21 @@ func (mr *MockStoreMockRecorder) GetChatFilesByIDs(ctx, ids any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFilesByIDs", reflect.TypeOf((*MockStore)(nil).GetChatFilesByIDs), ctx, ids)
}
// GetChatIncludeDefaultSystemPrompt mocks base method.
func (m *MockStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatIncludeDefaultSystemPrompt", ctx)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatIncludeDefaultSystemPrompt indicates an expected call of GetChatIncludeDefaultSystemPrompt.
func (mr *MockStoreMockRecorder) GetChatIncludeDefaultSystemPrompt(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).GetChatIncludeDefaultSystemPrompt), ctx)
}
// GetChatMessageByID mocks base method.
func (m *MockStore) GetChatMessageByID(ctx context.Context, id int64) (database.ChatMessage, error) {
m.ctrl.T.Helper()
@@ -2088,6 +2104,21 @@ func (mr *MockStoreMockRecorder) GetChatMessagesByChatID(ctx, arg any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesByChatID", reflect.TypeOf((*MockStore)(nil).GetChatMessagesByChatID), ctx, arg)
}
// GetChatMessagesByChatIDAscPaginated mocks base method.
func (m *MockStore) GetChatMessagesByChatIDAscPaginated(ctx context.Context, arg database.GetChatMessagesByChatIDAscPaginatedParams) ([]database.ChatMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatMessagesByChatIDAscPaginated", ctx, arg)
ret0, _ := ret[0].([]database.ChatMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatMessagesByChatIDAscPaginated indicates an expected call of GetChatMessagesByChatIDAscPaginated.
func (mr *MockStoreMockRecorder) GetChatMessagesByChatIDAscPaginated(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessagesByChatIDAscPaginated", reflect.TypeOf((*MockStore)(nil).GetChatMessagesByChatIDAscPaginated), ctx, arg)
}
// GetChatMessagesByChatIDDescPaginated mocks base method.
func (m *MockStore) GetChatMessagesByChatIDDescPaginated(ctx context.Context, arg database.GetChatMessagesByChatIDDescPaginatedParams) ([]database.ChatMessage, error) {
m.ctrl.T.Helper()
@@ -2223,6 +2254,21 @@ func (mr *MockStoreMockRecorder) GetChatSystemPrompt(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).GetChatSystemPrompt), ctx)
}
// GetChatSystemPromptConfig mocks base method.
func (m *MockStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatSystemPromptConfig", ctx)
ret0, _ := ret[0].(database.GetChatSystemPromptConfigRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatSystemPromptConfig indicates an expected call of GetChatSystemPromptConfig.
func (mr *MockStoreMockRecorder) GetChatSystemPromptConfig(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPromptConfig", reflect.TypeOf((*MockStore)(nil).GetChatSystemPromptConfig), ctx)
}
// GetChatTemplateAllowlist mocks base method.
func (m *MockStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
@@ -2299,10 +2345,10 @@ func (mr *MockStoreMockRecorder) GetChatWorkspaceTTL(ctx any) *gomock.Call {
}
// GetChats mocks base method.
func (m *MockStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
func (m *MockStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChats", ctx, arg)
ret0, _ := ret[0].([]database.Chat)
ret0, _ := ret[0].([]database.GetChatsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -2313,6 +2359,21 @@ func (mr *MockStoreMockRecorder) GetChats(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChats", reflect.TypeOf((*MockStore)(nil).GetChats), ctx, arg)
}
// GetChatsByWorkspaceIDs mocks base method.
func (m *MockStore) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatsByWorkspaceIDs", ctx, ids)
ret0, _ := ret[0].([]database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatsByWorkspaceIDs indicates an expected call of GetChatsByWorkspaceIDs.
func (mr *MockStoreMockRecorder) GetChatsByWorkspaceIDs(ctx, ids any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByWorkspaceIDs", reflect.TypeOf((*MockStore)(nil).GetChatsByWorkspaceIDs), ctx, ids)
}
// GetConnectionLogsOffset mocks base method.
func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
m.ctrl.T.Helper()
@@ -4578,6 +4639,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx)
}
// GetUserAISeatStates mocks base method.
func (m *MockStore) GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserAISeatStates", ctx, userIds)
ret0, _ := ret[0].([]uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserAISeatStates indicates an expected call of GetUserAISeatStates.
func (mr *MockStoreMockRecorder) GetUserAISeatStates(ctx, userIds any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAISeatStates", reflect.TypeOf((*MockStore)(nil).GetUserAISeatStates), ctx, userIds)
}
// GetUserActivityInsights mocks base method.
func (m *MockStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
m.ctrl.T.Helper()
@@ -6947,6 +7023,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), ctx, arg)
}
// ListAIBridgeClients mocks base method.
func (m *MockStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAIBridgeClients", ctx, arg)
ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAIBridgeClients indicates an expected call of ListAIBridgeClients.
func (mr *MockStoreMockRecorder) ListAIBridgeClients(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeClients", reflect.TypeOf((*MockStore)(nil).ListAIBridgeClients), ctx, arg)
}
// ListAIBridgeInterceptions mocks base method.
func (m *MockStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
m.ctrl.T.Helper()
@@ -6977,6 +7068,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeInterceptionsTelemetrySummaries(ctx
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeInterceptionsTelemetrySummaries", reflect.TypeOf((*MockStore)(nil).ListAIBridgeInterceptionsTelemetrySummaries), ctx, arg)
}
// ListAIBridgeModelThoughtsByInterceptionIDs mocks base method.
func (m *MockStore) ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]database.AIBridgeModelThought, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAIBridgeModelThoughtsByInterceptionIDs", ctx, interceptionIds)
ret0, _ := ret[0].([]database.AIBridgeModelThought)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAIBridgeModelThoughtsByInterceptionIDs indicates an expected call of ListAIBridgeModelThoughtsByInterceptionIDs.
func (mr *MockStoreMockRecorder) ListAIBridgeModelThoughtsByInterceptionIDs(ctx, interceptionIds any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeModelThoughtsByInterceptionIDs", reflect.TypeOf((*MockStore)(nil).ListAIBridgeModelThoughtsByInterceptionIDs), ctx, interceptionIds)
}
// ListAIBridgeModels mocks base method.
func (m *MockStore) ListAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams) ([]string, error) {
m.ctrl.T.Helper()
@@ -6992,6 +7098,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeModels(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAIBridgeModels), ctx, arg)
}
// ListAIBridgeSessionThreads mocks base method.
func (m *MockStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAIBridgeSessionThreads", ctx, arg)
ret0, _ := ret[0].([]database.ListAIBridgeSessionThreadsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAIBridgeSessionThreads indicates an expected call of ListAIBridgeSessionThreads.
func (mr *MockStoreMockRecorder) ListAIBridgeSessionThreads(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeSessionThreads", reflect.TypeOf((*MockStore)(nil).ListAIBridgeSessionThreads), ctx, arg)
}
// ListAIBridgeSessions mocks base method.
func (m *MockStore) ListAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams) ([]database.ListAIBridgeSessionsRow, error) {
m.ctrl.T.Helper()
@@ -7052,6 +7173,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeUserPromptsByInterceptionIDs(ctx, i
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeUserPromptsByInterceptionIDs", reflect.TypeOf((*MockStore)(nil).ListAIBridgeUserPromptsByInterceptionIDs), ctx, interceptionIds)
}
// ListAuthorizedAIBridgeClients mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeClients", ctx, arg, prepared)
ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAuthorizedAIBridgeClients indicates an expected call of ListAuthorizedAIBridgeClients.
func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeClients(ctx, arg, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeClients", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeClients), ctx, arg, prepared)
}
// ListAuthorizedAIBridgeInterceptions mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
m.ctrl.T.Helper()
@@ -7082,6 +7218,21 @@ func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeModels(ctx, arg, prepared
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeModels), ctx, arg, prepared)
}
// ListAuthorizedAIBridgeSessionThreads mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionThreadsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeSessionThreads", ctx, arg, prepared)
ret0, _ := ret[0].([]database.ListAIBridgeSessionThreadsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAuthorizedAIBridgeSessionThreads indicates an expected call of ListAuthorizedAIBridgeSessionThreads.
func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeSessionThreads(ctx, arg, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeSessionThreads", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeSessionThreads), ctx, arg, prepared)
}
// ListAuthorizedAIBridgeSessions mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionsRow, error) {
m.ctrl.T.Helper()
@@ -7306,6 +7457,20 @@ func (mr *MockStoreMockRecorder) PaginatedOrganizationMembers(ctx, arg any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PaginatedOrganizationMembers", reflect.TypeOf((*MockStore)(nil).PaginatedOrganizationMembers), ctx, arg)
}
// PinChatByID mocks base method.
func (m *MockStore) PinChatByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PinChatByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// PinChatByID indicates an expected call of PinChatByID.
func (mr *MockStoreMockRecorder) PinChatByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PinChatByID", reflect.TypeOf((*MockStore)(nil).PinChatByID), ctx, id)
}
// Ping mocks base method.
func (m *MockStore) Ping(ctx context.Context) (time.Duration, error) {
m.ctrl.T.Helper()
@@ -7468,11 +7633,12 @@ func (mr *MockStoreMockRecorder) TryAcquireLock(ctx, pgTryAdvisoryXactLock any)
}
// UnarchiveChatByID mocks base method.
func (m *MockStore) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error {
func (m *MockStore) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UnarchiveChatByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].([]database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UnarchiveChatByID indicates an expected call of UnarchiveChatByID.
@@ -7509,6 +7675,20 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id)
}
// UnpinChatByID mocks base method.
func (m *MockStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UnpinChatByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// UnpinChatByID indicates an expected call of UnpinChatByID.
func (mr *MockStoreMockRecorder) UnpinChatByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnpinChatByID", reflect.TypeOf((*MockStore)(nil).UnpinChatByID), ctx, id)
}
// UnsetDefaultChatModelConfigs mocks base method.
func (m *MockStore) UnsetDefaultChatModelConfigs(ctx context.Context) error {
m.ctrl.T.Helper()
@@ -7552,6 +7732,21 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg)
}
// UpdateChatBuildAgentBinding mocks base method.
func (m *MockStore) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatBuildAgentBinding", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatBuildAgentBinding indicates an expected call of UpdateChatBuildAgentBinding.
func (mr *MockStoreMockRecorder) UpdateChatBuildAgentBinding(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatBuildAgentBinding", reflect.TypeOf((*MockStore)(nil).UpdateChatBuildAgentBinding), ctx, arg)
}
// UpdateChatByID mocks base method.
func (m *MockStore) UpdateChatByID(ctx context.Context, arg database.UpdateChatByIDParams) (database.Chat, error) {
m.ctrl.T.Helper()
@@ -7597,6 +7792,50 @@ func (mr *MockStoreMockRecorder) UpdateChatLabelsByID(ctx, arg any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLabelsByID", reflect.TypeOf((*MockStore)(nil).UpdateChatLabelsByID), ctx, arg)
}
// UpdateChatLastInjectedContext mocks base method.
func (m *MockStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatLastInjectedContext", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatLastInjectedContext indicates an expected call of UpdateChatLastInjectedContext.
func (mr *MockStoreMockRecorder) UpdateChatLastInjectedContext(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastInjectedContext", reflect.TypeOf((*MockStore)(nil).UpdateChatLastInjectedContext), ctx, arg)
}
// UpdateChatLastModelConfigByID mocks base method.
func (m *MockStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatLastModelConfigByID", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatLastModelConfigByID indicates an expected call of UpdateChatLastModelConfigByID.
func (mr *MockStoreMockRecorder) UpdateChatLastModelConfigByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastModelConfigByID", reflect.TypeOf((*MockStore)(nil).UpdateChatLastModelConfigByID), ctx, arg)
}
// UpdateChatLastReadMessageID mocks base method.
func (m *MockStore) UpdateChatLastReadMessageID(ctx context.Context, arg database.UpdateChatLastReadMessageIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatLastReadMessageID", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateChatLastReadMessageID indicates an expected call of UpdateChatLastReadMessageID.
func (mr *MockStoreMockRecorder) UpdateChatLastReadMessageID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastReadMessageID", reflect.TypeOf((*MockStore)(nil).UpdateChatLastReadMessageID), ctx, arg)
}
// UpdateChatMCPServerIDs mocks base method.
func (m *MockStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) {
m.ctrl.T.Helper()
@@ -7642,6 +7881,20 @@ func (mr *MockStoreMockRecorder) UpdateChatModelConfig(ctx, arg any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatModelConfig", reflect.TypeOf((*MockStore)(nil).UpdateChatModelConfig), ctx, arg)
}
// UpdateChatPinOrder mocks base method.
func (m *MockStore) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatPinOrder", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateChatPinOrder indicates an expected call of UpdateChatPinOrder.
func (mr *MockStoreMockRecorder) UpdateChatPinOrder(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatPinOrder", reflect.TypeOf((*MockStore)(nil).UpdateChatPinOrder), ctx, arg)
}
// UpdateChatProvider mocks base method.
func (m *MockStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
m.ctrl.T.Helper()
@@ -7672,19 +7925,34 @@ func (mr *MockStoreMockRecorder) UpdateChatStatus(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatStatus", reflect.TypeOf((*MockStore)(nil).UpdateChatStatus), ctx, arg)
}
// UpdateChatWorkspace mocks base method.
func (m *MockStore) UpdateChatWorkspace(ctx context.Context, arg database.UpdateChatWorkspaceParams) (database.Chat, error) {
// UpdateChatStatusPreserveUpdatedAt mocks base method.
func (m *MockStore) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg database.UpdateChatStatusPreserveUpdatedAtParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatWorkspace", ctx, arg)
ret := m.ctrl.Call(m, "UpdateChatStatusPreserveUpdatedAt", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatWorkspace indicates an expected call of UpdateChatWorkspace.
func (mr *MockStoreMockRecorder) UpdateChatWorkspace(ctx, arg any) *gomock.Call {
// UpdateChatStatusPreserveUpdatedAt indicates an expected call of UpdateChatStatusPreserveUpdatedAt.
func (mr *MockStoreMockRecorder) UpdateChatStatusPreserveUpdatedAt(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatWorkspace", reflect.TypeOf((*MockStore)(nil).UpdateChatWorkspace), ctx, arg)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatStatusPreserveUpdatedAt", reflect.TypeOf((*MockStore)(nil).UpdateChatStatusPreserveUpdatedAt), ctx, arg)
}
// UpdateChatWorkspaceBinding mocks base method.
func (m *MockStore) UpdateChatWorkspaceBinding(ctx context.Context, arg database.UpdateChatWorkspaceBindingParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatWorkspaceBinding", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatWorkspaceBinding indicates an expected call of UpdateChatWorkspaceBinding.
func (mr *MockStoreMockRecorder) UpdateChatWorkspaceBinding(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatWorkspaceBinding", reflect.TypeOf((*MockStore)(nil).UpdateChatWorkspaceBinding), ctx, arg)
}
// UpdateCryptoKeyDeletesAt mocks base method.
@@ -9029,6 +9297,20 @@ func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg)
}
// UpsertChatIncludeDefaultSystemPrompt mocks base method.
func (m *MockStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatIncludeDefaultSystemPrompt", ctx, includeDefaultSystemPrompt)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatIncludeDefaultSystemPrompt indicates an expected call of UpsertChatIncludeDefaultSystemPrompt.
func (mr *MockStoreMockRecorder) UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatIncludeDefaultSystemPrompt), ctx, includeDefaultSystemPrompt)
}
// UpsertChatSystemPrompt mocks base method.
func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error {
m.ctrl.T.Helper()
+17 -2
View File
@@ -1100,7 +1100,8 @@ CREATE TABLE aibridge_interceptions (
thread_parent_id uuid,
thread_root_id uuid,
client_session_id character varying(256),
session_id text GENERATED ALWAYS AS (COALESCE(client_session_id, ((thread_root_id)::text)::character varying, ((id)::text)::character varying)) STORED NOT NULL
session_id text GENERATED ALWAYS AS (COALESCE(client_session_id, ((thread_root_id)::text)::character varying, ((id)::text)::character varying)) STORED NOT NULL,
provider_name text DEFAULT ''::text NOT NULL
);
COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge';
@@ -1115,6 +1116,8 @@ COMMENT ON COLUMN aibridge_interceptions.client_session_id IS 'The session ID su
COMMENT ON COLUMN aibridge_interceptions.session_id IS 'Groups related interceptions into a logical session. Determined by a priority chain: (1) client_session_id — an explicit session identifier supplied by the calling client (e.g. Claude Code); (2) thread_root_id — the root of an agentic thread detected by Bridge through tool-call correlation, used when the client does not supply its own session ID; (3) id — the interception''s own ID, used as a last resort so every interception belongs to exactly one session even if it is standalone. This is a generated column stored on disk so it can be indexed and joined without recomputing the COALESCE on every query.';
COMMENT ON COLUMN aibridge_interceptions.provider_name IS 'The provider instance name which may differ from provider when multiple instances of the same provider type exist.';
CREATE TABLE aibridge_model_thoughts (
interception_id uuid NOT NULL,
content text NOT NULL,
@@ -1399,7 +1402,12 @@ CREATE TABLE chats (
last_error text,
mode chat_mode,
mcp_server_ids uuid[] DEFAULT '{}'::uuid[] NOT NULL,
labels jsonb DEFAULT '{}'::jsonb NOT NULL
labels jsonb DEFAULT '{}'::jsonb NOT NULL,
build_id uuid,
agent_id uuid,
pin_order integer DEFAULT 0 NOT NULL,
last_read_message_id bigint,
last_injected_context jsonb
);
CREATE TABLE connection_logs (
@@ -1703,6 +1711,7 @@ CREATE TABLE mcp_server_configs (
updated_by uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
model_intent boolean DEFAULT false NOT NULL,
CONSTRAINT mcp_server_configs_auth_type_check CHECK ((auth_type = ANY (ARRAY['none'::text, 'oauth2'::text, 'api_key'::text, 'custom_headers'::text]))),
CONSTRAINT mcp_server_configs_availability_check CHECK ((availability = ANY (ARRAY['force_on'::text, 'default_on'::text, 'default_off'::text]))),
CONSTRAINT mcp_server_configs_transport_check CHECK ((transport = ANY (ARRAY['streamable_http'::text, 'sse'::text])))
@@ -4033,6 +4042,12 @@ ALTER TABLE ONLY chat_providers
ALTER TABLE ONLY chat_queued_messages
ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chats
ADD CONSTRAINT chats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL;
ALTER TABLE ONLY chats
ADD CONSTRAINT chats_build_id_fkey FOREIGN KEY (build_id) REFERENCES workspace_builds(id) ON DELETE SET NULL;
ALTER TABLE ONLY chats
ADD CONSTRAINT chats_last_model_config_id_fkey FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id);
@@ -20,6 +20,8 @@ const (
ForeignKeyChatProvidersAPIKeyKeyID ForeignKeyConstraint = "chat_providers_api_key_key_id_fkey" // ALTER TABLE ONLY chat_providers ADD CONSTRAINT chat_providers_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyChatProvidersCreatedBy ForeignKeyConstraint = "chat_providers_created_by_fkey" // ALTER TABLE ONLY chat_providers ADD CONSTRAINT chat_providers_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id);
ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatsAgentID ForeignKeyConstraint = "chats_agent_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL;
ForeignKeyChatsBuildID ForeignKeyConstraint = "chats_build_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_build_id_fkey FOREIGN KEY (build_id) REFERENCES workspace_builds(id) ON DELETE SET NULL;
ForeignKeyChatsLastModelConfigID ForeignKeyConstraint = "chats_last_model_config_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_last_model_config_id_fkey FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id);
ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatsParentChatID ForeignKeyConstraint = "chats_parent_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_parent_chat_id_fkey FOREIGN KEY (parent_chat_id) REFERENCES chats(id) ON DELETE SET NULL;
@@ -0,0 +1,3 @@
ALTER TABLE chats
DROP COLUMN IF EXISTS build_id,
DROP COLUMN IF EXISTS agent_id;
@@ -0,0 +1,3 @@
ALTER TABLE chats
ADD COLUMN build_id UUID REFERENCES workspace_builds(id) ON DELETE SET NULL,
ADD COLUMN agent_id UUID REFERENCES workspace_agents(id) ON DELETE SET NULL;
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN pin_order;
@@ -0,0 +1 @@
ALTER TABLE chats ADD COLUMN pin_order integer DEFAULT 0 NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE mcp_server_configs DROP COLUMN model_intent;
@@ -0,0 +1,2 @@
ALTER TABLE mcp_server_configs
ADD COLUMN model_intent BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN last_read_message_id;
@@ -0,0 +1,9 @@
ALTER TABLE chats ADD COLUMN last_read_message_id BIGINT;
-- Backfill existing chats so they don't appear unread after deploy.
-- The has_unread query uses COALESCE(last_read_message_id, 0), so
-- leaving this NULL would mark every existing chat as unread.
UPDATE chats SET last_read_message_id = (
SELECT MAX(cm.id) FROM chat_messages cm
WHERE cm.chat_id = chats.id AND cm.role = 'assistant' AND cm.deleted = false
);
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN last_injected_context;
@@ -0,0 +1 @@
ALTER TABLE chats ADD COLUMN last_injected_context JSONB;
@@ -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));
@@ -0,0 +1 @@
ALTER TABLE aibridge_interceptions DROP COLUMN provider_name;
@@ -0,0 +1,6 @@
ALTER TABLE aibridge_interceptions ADD COLUMN provider_name TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN aibridge_interceptions.provider_name IS 'The provider instance name which may differ from provider when multiple instances of the same provider type exist.';
-- Backfill existing records with the provider type as the provider name.
UPDATE aibridge_interceptions SET provider_name = provider WHERE provider_name = '';
+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")
}
+4
View File
@@ -178,6 +178,10 @@ func (c Chat) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String())
}
func (r GetChatsRow) RBACObject() rbac.Object {
return r.Chat.RBACObject()
}
func (c ChatFile) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
}
+119 -26
View File
@@ -741,10 +741,10 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
}
type chatQuerier interface {
GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]Chat, error)
GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error)
}
func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]Chat, error) {
func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChats())
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
@@ -769,29 +769,34 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
return nil, err
}
defer rows.Close()
var items []Chat
var items []GetChatsRow
for rows.Next() {
var i Chat
var i GetChatsRow
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
); err != nil {
&i.Chat.ID,
&i.Chat.OwnerID,
&i.Chat.WorkspaceID,
&i.Chat.Title,
&i.Chat.Status,
&i.Chat.WorkerID,
&i.Chat.StartedAt,
&i.Chat.HeartbeatAt,
&i.Chat.CreatedAt,
&i.Chat.UpdatedAt,
&i.Chat.ParentChatID,
&i.Chat.RootChatID,
&i.Chat.LastModelConfigID,
&i.Chat.Archived,
&i.Chat.LastError,
&i.Chat.Mode,
pq.Array(&i.Chat.MCPServerIDs),
&i.Chat.Labels,
&i.Chat.BuildID,
&i.Chat.AgentID,
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.HasUnread); err != nil {
return nil, err
}
items = append(items, i)
@@ -809,8 +814,10 @@ type aibridgeQuerier interface {
ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error)
CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error)
ListAuthorizedAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams, prepared rbac.PreparedAuthorized) ([]string, error)
ListAuthorizedAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error)
ListAuthorizedAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionsRow, error)
CountAuthorizedAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) (int64, error)
ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionThreadsRow, error)
}
func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error) {
@@ -858,6 +865,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
&i.AIBridgeInterception.ThreadRootID,
&i.AIBridgeInterception.ClientSessionID,
&i.AIBridgeInterception.SessionID,
&i.AIBridgeInterception.ProviderName,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
@@ -945,6 +953,35 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeModels(ctx context.Context, arg ListA
return items, nil
}
func (q *sqlQuerier) ListAuthorizedAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.AIBridgeInterceptionConverter(),
})
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(listAIBridgeClients, fmt.Sprintf(" AND %s", authorizedFilter))
if err != nil {
return nil, xerrors.Errorf("insert authorized filter: %w", err)
}
query := fmt.Sprintf("-- name: ListAIBridgeClients :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query, arg.Client, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var client string
if err := rows.Scan(&client); err != nil {
return nil, err
}
items = append(items, client)
}
return items, nil
}
func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionsRow, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.AIBridgeInterceptionConverter(),
@@ -960,8 +997,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,
@@ -969,6 +1004,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
@@ -1048,11 +1085,67 @@ func (q *sqlQuerier) CountAuthorizedAIBridgeSessions(ctx context.Context, arg Co
return count, nil
}
func (q *sqlQuerier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionThreadsRow, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.AIBridgeInterceptionConverter(),
})
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(listAIBridgeSessionThreads, fmt.Sprintf(" AND %s", authorizedFilter))
if err != nil {
return nil, xerrors.Errorf("insert authorized filter: %w", err)
}
query := fmt.Sprintf("-- name: ListAuthorizedAIBridgeSessionThreads :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.SessionID,
arg.AfterID,
arg.BeforeID,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAIBridgeSessionThreadsRow
for rows.Next() {
var i ListAIBridgeSessionThreadsRow
if err := rows.Scan(
&i.ThreadID,
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.AIBridgeInterception.EndedAt,
&i.AIBridgeInterception.APIKeyID,
&i.AIBridgeInterception.Client,
&i.AIBridgeInterception.ThreadParentID,
&i.AIBridgeInterception.ThreadRootID,
&i.AIBridgeInterception.ClientSessionID,
&i.AIBridgeInterception.SessionID,
&i.AIBridgeInterception.ProviderName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func insertAuthorizedFilter(query string, replaceWith string) (string, error) {
if !strings.Contains(query, authorizedQueryPlaceholder) {
return "", xerrors.Errorf("query does not contain authorized replace string, this is not an authorized query")
}
filtered := strings.Replace(query, authorizedQueryPlaceholder, replaceWith, 1)
filtered := strings.ReplaceAll(query, authorizedQueryPlaceholder, replaceWith)
return filtered, nil
}
+26 -18
View File
@@ -4038,6 +4038,8 @@ type AIBridgeInterception struct {
ClientSessionID sql.NullString `db:"client_session_id" json:"client_session_id"`
// Groups related interceptions into a logical session. Determined by a priority chain: (1) client_session_id — an explicit session identifier supplied by the calling client (e.g. Claude Code); (2) thread_root_id — the root of an agentic thread detected by Bridge through tool-call correlation, used when the client does not supply its own session ID; (3) id — the interception's own ID, used as a last resort so every interception belongs to exactly one session even if it is standalone. This is a generated column stored on disk so it can be indexed and joined without recomputing the COALESCE on every query.
SessionID string `db:"session_id" json:"session_id"`
// The provider instance name which may differ from provider when multiple instances of the same provider type exist.
ProviderName string `db:"provider_name" json:"provider_name"`
}
// Audit log of model thinking in intercepted requests in AI Bridge
@@ -4153,24 +4155,29 @@ type BoundaryUsageStat struct {
}
type Chat struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError sql.NullString `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError sql.NullString `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
}
type ChatDiffStatus struct {
@@ -4485,6 +4492,7 @@ type MCPServerConfig struct {
UpdatedBy uuid.NullUUID `db:"updated_by" json:"updated_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ModelIntent bool `db:"model_intent" json:"model_intent"`
}
type MCPServerUserToken struct {
+50 -4
View File
@@ -54,7 +54,7 @@ type sqlcQuerier interface {
ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error
// AllUserIDs returns all UserIDs regardless of user status or deletion.
AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error)
ArchiveChatByID(ctx context.Context, id uuid.UUID) error
ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, error)
// Archiving templates is a soft delete action, so is reversible.
// Archiving prevents the version from being used and discovered
// by listing.
@@ -243,8 +243,14 @@ type sqlcQuerier interface {
GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error)
GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error)
GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error)
// GetChatIncludeDefaultSystemPrompt preserves the legacy default
// for deployments created before the explicit include-default toggle.
// When the toggle is unset, a non-empty custom prompt implies false;
// otherwise the setting defaults to true.
GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error)
GetChatMessageByID(ctx context.Context, id int64) (ChatMessage, error)
GetChatMessagesByChatID(ctx context.Context, arg GetChatMessagesByChatIDParams) ([]ChatMessage, error)
GetChatMessagesByChatIDAscPaginated(ctx context.Context, arg GetChatMessagesByChatIDAscPaginatedParams) ([]ChatMessage, error)
GetChatMessagesByChatIDDescPaginated(ctx context.Context, arg GetChatMessagesByChatIDDescPaginatedParams) ([]ChatMessage, error)
GetChatMessagesForPromptByChatID(ctx context.Context, chatID uuid.UUID) ([]ChatMessage, error)
GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error)
@@ -254,6 +260,12 @@ type sqlcQuerier interface {
GetChatProviders(ctx context.Context) ([]ChatProvider, error)
GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error)
GetChatSystemPrompt(ctx context.Context) (string, error)
// GetChatSystemPromptConfig returns both chat system prompt settings in a
// single read to avoid torn reads between separate site-config lookups.
// The include-default fallback preserves the legacy behavior where a
// non-empty custom prompt implied opting out before the explicit toggle
// existed.
GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error)
// GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
// Returns an empty string when no allowlist has been configured (all templates allowed).
GetChatTemplateAllowlist(ctx context.Context) (string, error)
@@ -263,7 +275,8 @@ type sqlcQuerier interface {
// Returns the global TTL for chat workspaces as a Go duration string.
// Returns "0s" (disabled) when no value has been configured.
GetChatWorkspaceTTL(ctx context.Context) (string, error)
GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, error)
GetChats(ctx context.Context, arg GetChatsParams) ([]GetChatsRow, error)
GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]Chat, error)
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
GetCryptoKeys(ctx context.Context) ([]CryptoKey, error)
@@ -548,6 +561,10 @@ type sqlcQuerier interface {
// inclusive.
GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error)
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
// Returns user IDs from the provided list that are consuming an AI seat.
// Filters to active, non-deleted, non-system users to match the canonical
// seat count query (GetActiveAISeatCount).
GetUserAISeatStates(ctx context.Context, userIds []uuid.UUID) ([]uuid.UUID, error)
// GetUserActivityInsights returns the ranking with top active users.
// The result can be filtered on template_ids, meaning only user data
// from workspaces based on those templates will be included.
@@ -758,14 +775,23 @@ type sqlcQuerier interface {
InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error)
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
ListAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams) ([]string, error)
ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, error)
// Finds all unique AI Bridge interception telemetry summaries combinations
// (provider, model, client) in the given timeframe for telemetry reporting.
ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg ListAIBridgeInterceptionsTelemetrySummariesParams) ([]ListAIBridgeInterceptionsTelemetrySummariesRow, error)
ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeModelThought, error)
ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error)
// Returns all interceptions belonging to paginated threads within a session.
// Threads are paginated by (started_at, thread_id) cursor.
ListAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams) ([]ListAIBridgeSessionThreadsRow, error)
// 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)
@@ -789,6 +815,12 @@ type sqlcQuerier interface {
// - Use both to get a specific org member row
OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error)
PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error)
// Under READ COMMITTED, concurrent pin operations for the same
// owner may momentarily produce duplicate pin_order values because
// each CTE snapshot does not see the other's writes. The next
// pin/unpin/reorder operation's ROW_NUMBER() self-heals the
// sequence, so this is acceptable.
PinChatByID(ctx context.Context, id uuid.UUID) error
PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error)
ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error
RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error)
@@ -812,24 +844,37 @@ type sqlcQuerier interface {
// This must be called from within a transaction. The lock will be automatically
// released when the transaction ends.
TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error)
UnarchiveChatByID(ctx context.Context, id uuid.UUID) error
UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, error)
// This will always work regardless of the current state of the template version.
UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error
UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error
UnpinChatByID(ctx context.Context, id uuid.UUID) error
UnsetDefaultChatModelConfigs(ctx context.Context) error
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error)
UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error)
// Bumps the heartbeat timestamp for a running chat so that other
// replicas know the worker is still alive.
UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error)
UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLabelsByIDParams) (Chat, error)
// Updates the cached injected context parts (AGENTS.md +
// skills) on the chat row. Called only when context changes
// (first workspace attach or agent change). updated_at is
// intentionally not touched to avoid reordering the chat list.
UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error)
UpdateChatLastModelConfigByID(ctx context.Context, arg UpdateChatLastModelConfigByIDParams) (Chat, error)
// Updates the last read message ID for a chat. This is used to track
// which messages the owner has seen, enabling unread indicators.
UpdateChatLastReadMessageID(ctx context.Context, arg UpdateChatLastReadMessageIDParams) error
UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error)
UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error)
UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error)
UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error
UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error)
UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error)
UpdateChatWorkspace(ctx context.Context, arg UpdateChatWorkspaceParams) (Chat, error)
UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error)
UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error)
UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error)
UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error)
UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error)
@@ -936,6 +981,7 @@ type sqlcQuerier interface {
UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error
UpsertChatSystemPrompt(ctx context.Context, value string) error
UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error
UpsertChatUsageLimitConfig(ctx context.Context, arg UpsertChatUsageLimitConfigParams) (ChatUsageLimitConfig, error)
+329 -12
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)
@@ -1281,6 +1285,7 @@ func TestGetAuthorizedChats(t *testing.T) {
// Create 3 chats owned by owner.
for i := range 3 {
_, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: fmt.Sprintf("owner chat %d", i+1),
@@ -1291,6 +1296,7 @@ func TestGetAuthorizedChats(t *testing.T) {
// Create 2 chats owned by member.
for i := range 2 {
_, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: member.ID,
LastModelConfigID: modelCfg.ID,
Title: fmt.Sprintf("member chat %d", i+1),
@@ -1311,7 +1317,7 @@ func TestGetAuthorizedChats(t *testing.T) {
require.NoError(t, err)
require.Len(t, memberRows, 2)
for _, row := range memberRows {
require.Equal(t, member.ID, row.OwnerID, "member should only see own chats")
require.Equal(t, member.ID, row.Chat.OwnerID, "member should only see own chats")
}
// Owner should see at least the 5 pre-created chats (site-wide
@@ -1381,7 +1387,7 @@ func TestGetAuthorizedChats(t *testing.T) {
require.NoError(t, err)
require.Len(t, memberRows, 2)
for _, row := range memberRows {
require.Equal(t, member.ID, row.OwnerID, "member should only see own chats")
require.Equal(t, member.ID, row.Chat.OwnerID, "member should only see own chats")
}
// As owner: should see at least the 5 pre-created chats.
@@ -1407,9 +1413,12 @@ 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{
Status: database.ChatStatusWaiting,
OwnerID: paginationUser.ID,
LastModelConfigID: modelCfg.ID,
Title: fmt.Sprintf("pagination chat %d", i+1),
@@ -1429,13 +1438,13 @@ func TestGetAuthorizedChats(t *testing.T) {
require.NoError(t, err)
require.Len(t, page1, 2)
for _, row := range page1 {
require.Equal(t, paginationUser.ID, row.OwnerID, "paginated results must belong to pagination user")
require.Equal(t, paginationUser.ID, row.Chat.OwnerID, "paginated results must belong to pagination user")
}
// Fetch remaining pages and collect all chat IDs.
allIDs := make(map[uuid.UUID]struct{})
for _, row := range page1 {
allIDs[row.ID] = struct{}{}
allIDs[row.Chat.ID] = struct{}{}
}
offset := int32(2)
for {
@@ -1445,8 +1454,8 @@ func TestGetAuthorizedChats(t *testing.T) {
}, preparedMember)
require.NoError(t, err)
for _, row := range page {
require.Equal(t, paginationUser.ID, row.OwnerID, "paginated results must belong to pagination user")
allIDs[row.ID] = struct{}{}
require.Equal(t, paginationUser.ID, row.Chat.OwnerID, "paginated results must belong to pagination user")
allIDs[row.Chat.ID] = struct{}{}
}
if len(page) < 2 {
break
@@ -9466,6 +9475,7 @@ func TestInsertChatMessages(t *testing.T) {
)
chat, err := store.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: user.ID,
LastModelConfigID: modelConfigA.ID,
Title: "test-chat-" + uuid.NewString(),
@@ -9635,6 +9645,7 @@ func TestGetChatMessagesForPromptByChatID(t *testing.T) {
newChat := func(t *testing.T) database.Chat {
t.Helper()
chat, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "test-chat-" + uuid.NewString(),
@@ -10008,6 +10019,7 @@ func TestGetPRInsights(t *testing.T) {
createChat := func(t *testing.T, store database.Store, userID, mcID uuid.UUID, title string) database.Chat {
t.Helper()
chat, err := store.InsertChat(context.Background(), database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: userID,
LastModelConfigID: mcID,
Title: title,
@@ -10143,6 +10155,7 @@ func TestGetPRInsights(t *testing.T) {
createChildChat := func(t *testing.T, store database.Store, userID, mcID, parentID, rootID uuid.UUID, title string) database.Chat {
t.Helper()
chat, err := store.InsertChat(context.Background(), database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: userID,
LastModelConfigID: mcID,
Title: title,
@@ -10487,6 +10500,187 @@ func TestGetPRInsights(t *testing.T) {
})
}
func TestChatPinOrderQueries(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
setup := func(t *testing.T) (context.Context, database.Store, uuid.UUID, uuid.UUID) {
t.Helper()
db, _ := dbtestutil.NewDB(t)
owner := dbgen.User(t, db, database.User{})
// Use background context for fixture setup so the
// timed test context doesn't tick during DB init.
bg := context.Background()
_, err := db.InsertChatProvider(bg, database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
})
require.NoError(t, err)
modelCfg, err := db.InsertChatModelConfig(bg, database.InsertChatModelConfigParams{
Provider: "openai",
Model: "test-model",
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
ctx := testutil.Context(t, testutil.WaitMedium)
return ctx, db, owner.ID, modelCfg.ID
}
createChat := func(t *testing.T, ctx context.Context, db database.Store, ownerID, modelCfgID uuid.UUID, title string) database.Chat {
t.Helper()
chat, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: ownerID,
LastModelConfigID: modelCfgID,
Title: title,
})
require.NoError(t, err)
return chat
}
requirePinOrders := func(t *testing.T, ctx context.Context, db database.Store, want map[uuid.UUID]int32) {
t.Helper()
for chatID, wantPinOrder := range want {
chat, err := db.GetChatByID(ctx, chatID)
require.NoError(t, err)
require.EqualValues(t, wantPinOrder, chat.PinOrder)
}
}
t.Run("PinChatByIDAppendsWithinOwner", func(t *testing.T) {
t.Parallel()
ctx, db, ownerID, modelCfgID := setup(t)
first := createChat(t, ctx, db, ownerID, modelCfgID, "first")
second := createChat(t, ctx, db, ownerID, modelCfgID, "second")
third := createChat(t, ctx, db, ownerID, modelCfgID, "third")
otherOwner := dbgen.User(t, db, database.User{})
other := createChat(t, ctx, db, otherOwner.ID, modelCfgID, "other-owner")
require.NoError(t, db.PinChatByID(ctx, other.ID))
require.NoError(t, db.PinChatByID(ctx, first.ID))
require.NoError(t, db.PinChatByID(ctx, second.ID))
require.NoError(t, db.PinChatByID(ctx, third.ID))
requirePinOrders(t, ctx, db, map[uuid.UUID]int32{
first.ID: 1,
second.ID: 2,
third.ID: 3,
other.ID: 1,
})
})
t.Run("UpdateChatPinOrderShiftsNeighborsAndClamps", func(t *testing.T) {
t.Parallel()
ctx, db, ownerID, modelCfgID := setup(t)
first := createChat(t, ctx, db, ownerID, modelCfgID, "first")
second := createChat(t, ctx, db, ownerID, modelCfgID, "second")
third := createChat(t, ctx, db, ownerID, modelCfgID, "third")
for _, chat := range []database.Chat{first, second, third} {
require.NoError(t, db.PinChatByID(ctx, chat.ID))
}
require.NoError(t, db.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{
ID: third.ID,
PinOrder: 1,
}))
requirePinOrders(t, ctx, db, map[uuid.UUID]int32{
first.ID: 2,
second.ID: 3,
third.ID: 1,
})
require.NoError(t, db.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{
ID: third.ID,
PinOrder: 99,
}))
requirePinOrders(t, ctx, db, map[uuid.UUID]int32{
first.ID: 1,
second.ID: 2,
third.ID: 3,
})
})
t.Run("UnpinChatByIDCompactsPinnedChats", func(t *testing.T) {
t.Parallel()
ctx, db, ownerID, modelCfgID := setup(t)
first := createChat(t, ctx, db, ownerID, modelCfgID, "first")
second := createChat(t, ctx, db, ownerID, modelCfgID, "second")
third := createChat(t, ctx, db, ownerID, modelCfgID, "third")
for _, chat := range []database.Chat{first, second, third} {
require.NoError(t, db.PinChatByID(ctx, chat.ID))
}
require.NoError(t, db.UnpinChatByID(ctx, second.ID))
requirePinOrders(t, ctx, db, map[uuid.UUID]int32{
first.ID: 1,
second.ID: 0,
third.ID: 2,
})
})
t.Run("ArchiveClearsPinAndExcludesFromRanking", func(t *testing.T) {
t.Parallel()
ctx, db, ownerID, modelCfgID := setup(t)
first := createChat(t, ctx, db, ownerID, modelCfgID, "first")
second := createChat(t, ctx, db, ownerID, modelCfgID, "second")
third := createChat(t, ctx, db, ownerID, modelCfgID, "third")
for _, chat := range []database.Chat{first, second, third} {
require.NoError(t, db.PinChatByID(ctx, chat.ID))
}
// Archive the middle pin.
_, err := db.ArchiveChatByID(ctx, second.ID)
require.NoError(t, err)
// Archived chat should have pin_order cleared. Remaining
// pins keep their original positions; the next mutation
// compacts via ROW_NUMBER().
requirePinOrders(t, ctx, db, map[uuid.UUID]int32{
first.ID: 1,
second.ID: 0,
third.ID: 3,
})
// Reorder among remaining active pins — archived chat
// should not interfere with position calculation.
require.NoError(t, db.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{
ID: third.ID,
PinOrder: 1,
}))
// After reorder, ROW_NUMBER() compacts the sequence.
requirePinOrders(t, ctx, db, map[uuid.UUID]int32{
first.ID: 2,
second.ID: 0,
third.ID: 1,
})
})
}
func TestChatLabels(t *testing.T) {
t.Parallel()
if testing.Short() {
@@ -10532,6 +10726,7 @@ func TestChatLabels(t *testing.T) {
require.NoError(t, err)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "labeled-chat",
@@ -10554,6 +10749,7 @@ func TestChatLabels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "no-labels-chat",
@@ -10569,6 +10765,7 @@ func TestChatLabels(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "update-labels-chat",
@@ -10609,6 +10806,7 @@ func TestChatLabels(t *testing.T) {
require.NoError(t, err)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: "original-title",
@@ -10645,6 +10843,7 @@ func TestChatLabels(t *testing.T) {
labelsJSON, err := json.Marshal(tc.labels)
require.NoError(t, err)
_, err = db.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: owner.ID,
LastModelConfigID: modelCfg.ID,
Title: tc.title,
@@ -10670,7 +10869,7 @@ func TestChatLabels(t *testing.T) {
titles := make([]string, 0, len(results))
for _, c := range results {
titles = append(titles, c.Title)
titles = append(titles, c.Chat.Title)
}
require.Contains(t, titles, "filter-a")
require.Contains(t, titles, "filter-b")
@@ -10688,8 +10887,7 @@ func TestChatLabels(t *testing.T) {
})
require.NoError(t, err)
require.Len(t, results, 1)
require.Equal(t, "filter-a", results[0].Title)
require.Equal(t, "filter-a", results[0].Chat.Title)
// No filter — should return all chats for this owner.
allChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnerID: owner.ID,
@@ -10698,3 +10896,122 @@ func TestChatLabels(t *testing.T) {
require.GreaterOrEqual(t, len(allChats), 3)
})
}
func TestChatHasUnread(t *testing.T) {
t.Parallel()
store, _ := dbtestutil.NewDB(t)
ctx := context.Background()
dbgen.Organization(t, store, database.Organization{})
user := dbgen.User(t, store, database.User{})
_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
APIKey: "test-key",
Enabled: true,
})
require.NoError(t, err)
modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: "openai",
Model: "test-model-" + uuid.NewString(),
DisplayName: "Test Model",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
chat, err := store.InsertChat(ctx, database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "test-chat-" + uuid.NewString(),
})
require.NoError(t, err)
getHasUnread := func() bool {
rows, err := store.GetChats(ctx, database.GetChatsParams{
OwnerID: user.ID,
})
require.NoError(t, err)
for _, row := range rows {
if row.Chat.ID == chat.ID {
return row.HasUnread
}
}
t.Fatal("chat not found in GetChats result")
return false
}
// New chat with no messages: not unread.
require.False(t, getHasUnread(), "new chat with no messages should not be unread")
// Helper to insert a single chat message.
insertMsg := func(role database.ChatMessageRole, text string) {
t.Helper()
_, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{
ChatID: chat.ID,
CreatedBy: []uuid.UUID{user.ID},
ModelConfigID: []uuid.UUID{modelCfg.ID},
Role: []database.ChatMessageRole{role},
Content: []string{fmt.Sprintf(`[{"type":"text","text":%q}]`, text)},
ContentVersion: []int16{0},
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
InputTokens: []int64{0},
OutputTokens: []int64{0},
TotalTokens: []int64{0},
ReasoningTokens: []int64{0},
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
ProviderResponseID: []string{""},
})
require.NoError(t, err)
}
// Insert an assistant message: becomes unread.
insertMsg(database.ChatMessageRoleAssistant, "hello")
require.True(t, getHasUnread(), "chat with unread assistant message should be unread")
// Mark as read: no longer unread.
lastMsg, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chat.ID,
Role: database.ChatMessageRoleAssistant,
})
require.NoError(t, err)
err = store.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chat.ID,
LastReadMessageID: lastMsg.ID,
})
require.NoError(t, err)
require.False(t, getHasUnread(), "chat should not be unread after marking as read")
// Insert another assistant message: becomes unread again.
insertMsg(database.ChatMessageRoleAssistant, "new message")
require.True(t, getHasUnread(), "new assistant message after read should be unread")
// Mark as read again, then verify user messages don't
// trigger unread.
lastMsg, err = store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chat.ID,
Role: database.ChatMessageRoleAssistant,
})
require.NoError(t, err)
err = store.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chat.ID,
LastReadMessageID: lastMsg.ID,
})
require.NoError(t, err)
insertMsg(database.ChatMessageRoleUser, "user msg")
require.False(t, getHasUnread(), "user messages should not trigger unread")
}
File diff suppressed because it is too large Load Diff
+166 -79
View File
@@ -1,8 +1,8 @@
-- name: InsertAIBridgeInterception :one
INSERT INTO aibridge_interceptions (
id, api_key_id, initiator_id, provider, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
id, api_key_id, initiator_id, provider, provider_name, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
) VALUES (
@id, @api_key_id, @initiator_id, @provider, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at, @client, sqlc.narg('client_session_id'), sqlc.narg('thread_parent_interception_id')::uuid, sqlc.narg('thread_root_interception_id')::uuid
@id, @api_key_id, @initiator_id, @provider, @provider_name, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at, @client, sqlc.narg('client_session_id'), sqlc.narg('thread_parent_interception_id')::uuid, sqlc.narg('thread_root_interception_id')::uuid
)
RETURNING *;
@@ -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,47 +547,114 @@ 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
ORDER BY
sp.started_at DESC,
sp.session_id DESC
;
-- name: ListAIBridgeSessionThreads :many
-- Returns all interceptions belonging to paginated threads within a session.
-- Threads are paginated by (started_at, thread_id) cursor.
WITH paginated_threads AS (
SELECT
-- Find thread root interceptions (thread_root_id IS NULL), apply cursor
-- pagination, and return the page.
aibridge_interceptions.id AS thread_id,
aibridge_interceptions.started_at
FROM
aibridge_interceptions
WHERE
aibridge_interceptions.session_id = @session_id::text
AND aibridge_interceptions.ended_at IS NOT NULL
AND aibridge_interceptions.thread_root_id IS NULL
-- Pagination cursor.
AND (@after_id::uuid = '00000000-0000-0000-0000-000000000000'::uuid OR
(aibridge_interceptions.started_at, aibridge_interceptions.id) > (
(SELECT started_at FROM aibridge_interceptions ai2 WHERE ai2.id = @after_id),
@after_id::uuid
)
)
ELSE true
END
AND (@before_id::uuid = '00000000-0000-0000-0000-000000000000'::uuid OR
(aibridge_interceptions.started_at, aibridge_interceptions.id) < (
(SELECT started_at FROM aibridge_interceptions ai2 WHERE ai2.id = @before_id),
@before_id::uuid
)
)
-- @authorize_filter
ORDER BY
aibridge_interceptions.started_at ASC,
aibridge_interceptions.id ASC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 50)
)
SELECT
COALESCE(aibridge_interceptions.thread_root_id, aibridge_interceptions.id) AS thread_id,
sqlc.embed(aibridge_interceptions)
FROM
aibridge_interceptions
JOIN
paginated_threads pt
ON pt.thread_id = COALESCE(aibridge_interceptions.thread_root_id, aibridge_interceptions.id)
WHERE
aibridge_interceptions.session_id = @session_id::text
AND aibridge_interceptions.ended_at IS NOT NULL
-- @authorize_filter
ORDER BY
sr.started_at DESC,
sr.session_id DESC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
-- Ensure threads and their associated interceptions (agentic loops) are sorted chronologically.
pt.started_at ASC,
pt.thread_id ASC,
aibridge_interceptions.started_at ASC,
aibridge_interceptions.id ASC
;
-- name: ListAIBridgeModelThoughtsByInterceptionIDs :many
SELECT
*
FROM
aibridge_model_thoughts
WHERE
interception_id = ANY(@interception_ids::uuid[])
ORDER BY
created_at ASC;
-- name: ListAIBridgeModels :many
SELECT
model
@@ -616,3 +679,27 @@ ORDER BY
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
;
-- name: ListAIBridgeClients :many
SELECT
COALESCE(client, 'Unknown') AS client
FROM
aibridge_interceptions
WHERE
ended_at IS NOT NULL
-- Filter client (prefix match to allow B-tree index usage).
AND CASE
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') LIKE @client::text || '%'
ELSE true
END
-- We use an `@authorize_filter` as we are attempting to list clients
-- that are relevant to the user and what they are allowed to see.
-- Authorize Filter clause will be injected below in
-- ListAIBridgeClientsAuthorized.
-- @authorize_filter
GROUP BY
client
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
;
+17
View File
@@ -0,0 +1,17 @@
-- name: GetUserAISeatStates :many
-- Returns user IDs from the provided list that are consuming an AI seat.
-- Filters to active, non-deleted, non-system users to match the canonical
-- seat count query (GetActiveAISeatCount).
SELECT
ais.user_id
FROM
ai_seat_state ais
JOIN
users u
ON
ais.user_id = u.id
WHERE
ais.user_id = ANY(@user_ids::uuid[])
AND u.status = 'active'::user_status
AND u.deleted = false
AND u.is_system = false;
+281 -12
View File
@@ -1,9 +1,192 @@
-- name: ArchiveChatByID :exec
UPDATE chats SET archived = true, updated_at = NOW()
WHERE id = @id OR root_chat_id = @id;
-- name: ArchiveChatByID :many
WITH chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = @id::uuid OR root_chat_id = @id::uuid
RETURNING *
)
SELECT *
FROM chats
ORDER BY (id = @id::uuid) DESC, created_at ASC, id ASC;
-- name: UnarchiveChatByID :exec
UPDATE chats SET archived = false, updated_at = NOW() WHERE id = @id::uuid;
-- name: UnarchiveChatByID :many
WITH chats AS (
UPDATE chats
SET archived = false, updated_at = NOW()
WHERE id = @id::uuid OR root_chat_id = @id::uuid
RETURNING *
)
SELECT *
FROM chats
ORDER BY (id = @id::uuid) DESC, created_at ASC, id ASC;
-- name: PinChatByID :exec
WITH target_chat AS (
SELECT
id,
owner_id
FROM
chats
WHERE
id = @id::uuid
),
-- Under READ COMMITTED, concurrent pin operations for the same
-- owner may momentarily produce duplicate pin_order values because
-- each CTE snapshot does not see the other's writes. The next
-- pin/unpin/reorder operation's ROW_NUMBER() self-heals the
-- sequence, so this is acceptable.
ranked AS (
SELECT
c.id,
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS next_pin_order
FROM
chats c
JOIN
target_chat ON c.owner_id = target_chat.owner_id
WHERE
c.pin_order > 0
AND c.archived = FALSE
AND c.id <> target_chat.id
),
updates AS (
SELECT
ranked.id,
ranked.next_pin_order AS pin_order
FROM
ranked
UNION ALL
SELECT
target_chat.id,
COALESCE((
SELECT
MAX(ranked.next_pin_order)
FROM
ranked
), 0) + 1 AS pin_order
FROM
target_chat
)
UPDATE
chats c
SET
pin_order = updates.pin_order
FROM
updates
WHERE
c.id = updates.id;
-- name: UnpinChatByID :exec
WITH target_chat AS (
SELECT
id,
owner_id
FROM
chats
WHERE
id = @id::uuid
),
ranked AS (
SELECT
c.id,
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position
FROM
chats c
JOIN
target_chat ON c.owner_id = target_chat.owner_id
WHERE
c.pin_order > 0
AND c.archived = FALSE
),
target AS (
SELECT
ranked.id,
ranked.current_position
FROM
ranked
WHERE
ranked.id = @id::uuid
),
updates AS (
SELECT
ranked.id,
CASE
WHEN ranked.id = target.id THEN 0
WHEN ranked.current_position > target.current_position THEN ranked.current_position - 1
ELSE ranked.current_position
END AS pin_order
FROM
ranked
CROSS JOIN
target
)
UPDATE
chats c
SET
pin_order = updates.pin_order
FROM
updates
WHERE
c.id = updates.id;
-- name: UpdateChatPinOrder :exec
WITH target_chat AS (
SELECT
id,
owner_id
FROM
chats
WHERE
id = @id::uuid
),
ranked AS (
SELECT
c.id,
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position,
COUNT(*) OVER () :: integer AS pinned_count
FROM
chats c
JOIN
target_chat ON c.owner_id = target_chat.owner_id
WHERE
c.pin_order > 0
AND c.archived = FALSE
),
target AS (
SELECT
ranked.id,
ranked.current_position,
LEAST(GREATEST(@pin_order::integer, 1), ranked.pinned_count) AS desired_position
FROM
ranked
WHERE
ranked.id = @id::uuid
),
updates AS (
SELECT
ranked.id,
CASE
WHEN ranked.id = target.id THEN target.desired_position
WHEN target.desired_position < target.current_position
AND ranked.current_position >= target.desired_position
AND ranked.current_position < target.current_position THEN ranked.current_position + 1
WHEN target.desired_position > target.current_position
AND ranked.current_position > target.current_position
AND ranked.current_position <= target.desired_position THEN ranked.current_position - 1
ELSE ranked.current_position
END AS pin_order
FROM
ranked
CROSS JOIN
target
)
UPDATE
chats c
SET
pin_order = updates.pin_order
FROM
updates
WHERE
c.id = updates.id;
-- name: SoftDeleteChatMessagesAfterID :exec
UPDATE
@@ -52,6 +235,21 @@ WHERE
ORDER BY
created_at ASC;
-- name: GetChatMessagesByChatIDAscPaginated :many
SELECT
*
FROM
chat_messages
WHERE
chat_id = @chat_id::uuid
AND id > @after_id::bigint
AND visibility IN ('user', 'both')
AND deleted = false
ORDER BY
id ASC
LIMIT
COALESCE(NULLIF(@limit_val::int, 0), 50);
-- name: GetChatMessagesByChatIDDescPaginated :many
SELECT
*
@@ -130,7 +328,14 @@ ORDER BY
-- name: GetChats :many
SELECT
*
sqlc.embed(chats),
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats.id
AND cm.role = 'assistant'
AND cm.deleted = false
AND cm.id > COALESCE(chats.last_read_message_id, 0)
) AS has_unread
FROM
chats
WHERE
@@ -180,21 +385,27 @@ LIMIT
INSERT INTO chats (
owner_id,
workspace_id,
build_id,
agent_id,
parent_chat_id,
root_chat_id,
last_model_config_id,
title,
mode,
status,
mcp_server_ids,
labels
) VALUES (
@owner_id::uuid,
sqlc.narg('workspace_id')::uuid,
sqlc.narg('build_id')::uuid,
sqlc.narg('agent_id')::uuid,
sqlc.narg('parent_chat_id')::uuid,
sqlc.narg('root_chat_id')::uuid,
@last_model_config_id::uuid,
@title::text,
sqlc.narg('mode')::chat_mode,
@status::chat_status,
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb)
)
@@ -294,6 +505,17 @@ WHERE
RETURNING
*;
-- name: UpdateChatLastModelConfigByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
last_model_config_id = @last_model_config_id::uuid
WHERE
id = @id::uuid
RETURNING
*;
-- name: UpdateChatLabelsByID :one
UPDATE
chats
@@ -305,16 +527,34 @@ WHERE
RETURNING
*;
-- name: UpdateChatWorkspace :one
UPDATE
chats
SET
-- name: UpdateChatWorkspaceBinding :one
UPDATE chats SET
workspace_id = sqlc.narg('workspace_id')::uuid,
build_id = sqlc.narg('build_id')::uuid,
agent_id = sqlc.narg('agent_id')::uuid,
updated_at = NOW()
WHERE id = @id::uuid
RETURNING *;
-- name: UpdateChatBuildAgentBinding :one
UPDATE chats SET
build_id = sqlc.narg('build_id')::uuid,
agent_id = sqlc.narg('agent_id')::uuid,
updated_at = NOW()
WHERE
id = @id::uuid
RETURNING
*;
RETURNING *;
-- name: UpdateChatLastInjectedContext :one
-- Updates the cached injected context parts (AGENTS.md +
-- skills) on the chat row. Called only when context changes
-- (first workspace attach or agent change). updated_at is
-- intentionally not touched to avoid reordering the chat list.
UPDATE chats SET
last_injected_context = sqlc.narg('last_injected_context')::jsonb
WHERE
id = @id::uuid
RETURNING *;
-- name: UpdateChatMCPServerIDs :one
UPDATE
@@ -371,6 +611,21 @@ WHERE
RETURNING
*;
-- name: UpdateChatStatusPreserveUpdatedAt :one
UPDATE
chats
SET
status = @status::chat_status,
worker_id = sqlc.narg('worker_id')::uuid,
started_at = sqlc.narg('started_at')::timestamptz,
heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
last_error = sqlc.narg('last_error')::text,
updated_at = @updated_at::timestamptz
WHERE
id = @id::uuid
RETURNING
*;
-- name: GetStaleChats :many
-- Find chats that appear stuck (running but heartbeat has expired).
-- Used for recovery after coderd crashes or long hangs.
@@ -878,6 +1133,13 @@ JOIN group_members_expanded gme ON gme.group_id = g.id
WHERE gme.user_id = @user_id::uuid
AND g.chat_spend_limit_micros IS NOT NULL;
-- name: GetChatsByWorkspaceIDs :many
SELECT *
FROM chats
WHERE archived = false
AND workspace_id = ANY(@ids::uuid[])
ORDER BY workspace_id, updated_at DESC;
-- name: ResolveUserChatSpendLimit :one
-- Resolves the effective spend limit for a user using the hierarchy:
-- 1. Individual user override (highest priority)
@@ -905,3 +1167,10 @@ LEFT JOIN LATERAL (
) gl ON TRUE
WHERE u.id = @user_id::uuid
LIMIT 1;
-- name: UpdateChatLastReadMessageID :exec
-- Updates the last read message ID for a chat. This is used to track
-- which messages the owner has seen, enabling unread indicators.
UPDATE chats
SET last_read_message_id = @last_read_message_id::bigint
WHERE id = @id::uuid;
@@ -77,6 +77,7 @@ INSERT INTO mcp_server_configs (
tool_deny_list,
availability,
enabled,
model_intent,
created_by,
updated_by
) VALUES (
@@ -102,6 +103,7 @@ INSERT INTO mcp_server_configs (
@tool_deny_list::text[],
@availability::text,
@enabled::boolean,
@model_intent::boolean,
@created_by::uuid,
@updated_by::uuid
)
@@ -134,6 +136,7 @@ SET
tool_deny_list = @tool_deny_list::text[],
availability = @availability::text,
enabled = @enabled::boolean,
model_intent = @model_intent::boolean,
updated_by = @updated_by::uuid,
updated_at = NOW()
WHERE
+50
View File
@@ -137,6 +137,24 @@ SELECT
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt;
-- GetChatSystemPromptConfig returns both chat system prompt settings in a
-- single read to avoid torn reads between separate site-config lookups.
-- The include-default fallback preserves the legacy behavior where a
-- non-empty custom prompt implied opting out before the explicit toggle
-- existed.
-- name: GetChatSystemPromptConfig :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt,
COALESCE(
(SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'),
NOT EXISTS (
SELECT 1
FROM site_configs
WHERE key = 'agents_chat_system_prompt'
AND value != ''
)
) :: boolean AS include_default_system_prompt;
-- name: UpsertChatSystemPrompt :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt';
@@ -167,6 +185,38 @@ WHERE site_configs.key = 'agents_desktop_enabled';
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist;
-- GetChatIncludeDefaultSystemPrompt preserves the legacy default
-- for deployments created before the explicit include-default toggle.
-- When the toggle is unset, a non-empty custom prompt implies false;
-- otherwise the setting defaults to true.
-- name: GetChatIncludeDefaultSystemPrompt :one
SELECT
COALESCE(
(SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_include_default_system_prompt'),
NOT EXISTS (
SELECT 1
FROM site_configs
WHERE key = 'agents_chat_system_prompt'
AND value != ''
)
) :: boolean AS include_default_system_prompt;
-- name: UpsertChatIncludeDefaultSystemPrompt :exec
INSERT INTO site_configs (key, value)
VALUES (
'agents_chat_include_default_system_prompt',
CASE
WHEN sqlc.arg(include_default_system_prompt)::bool THEN 'true'
ELSE 'false'
END
)
ON CONFLICT (key) DO UPDATE
SET value = CASE
WHEN sqlc.arg(include_default_system_prompt)::bool THEN 'true'
ELSE 'false'
END
WHERE site_configs.key = 'agents_chat_include_default_system_prompt';
-- name: GetChatWorkspaceTTL :one
-- Returns the global TTL for chat workspaces as a Go duration string.
-- Returns "0s" (disabled) when no value has been configured.
+357 -177
View File
@@ -33,6 +33,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/coder/v2/coderd/httpapi"
@@ -55,7 +56,6 @@ import (
)
const (
chatDiffStatusTTL = gitsync.DiffStatusTTL
chatStreamBatchSize = 256
chatContextLimitModelConfigKey = "context_limit"
@@ -66,11 +66,12 @@ const (
maxSystemPromptLenBytes = 131072 // 128 KiB
)
// chatGitRef holds the branch and remote origin reported by the
// workspace agent during a git operation.
// chatGitRef holds the branch, remote origin, and optional chat
// ID reported by the workspace agent during a git operation.
type chatGitRef struct {
Branch string
RemoteOrigin string
ChatID uuid.UUID
}
type chatRepositoryRef struct {
@@ -110,6 +111,28 @@ func maybeWriteLimitErr(ctx context.Context, rw http.ResponseWriter, err error)
return false
}
func publishChatConfigEvent(logger slog.Logger, ps dbpubsub.Pubsub, kind pubsub.ChatConfigEventKind, entityID uuid.UUID) {
payload, err := json.Marshal(pubsub.ChatConfigEvent{
Kind: kind,
EntityID: entityID,
})
if err != nil {
logger.Error(context.Background(), "failed to marshal chat config event",
slog.F("kind", kind),
slog.F("entity_id", entityID),
slog.Error(err),
)
return
}
if err := ps.Publish(pubsub.ChatConfigEventChannel, payload); err != nil {
logger.Error(context.Background(), "failed to publish chat config event",
slog.F("kind", kind),
slog.F("entity_id", entityID),
slog.Error(err),
)
}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -173,6 +196,88 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
}
}
// EXPERIMENTAL: chatsByWorkspace returns a mapping of workspace ID to
// the latest non-archived chat ID for each requested workspace.
// The query returns all matching chats and RBAC post-filters them;
// the handler then picks the latest per workspace in Go. This avoids
// the DISTINCT ON + post-filter bug where the sole candidate is
// silently dropped when the caller can't read it.
//
// TODO:
// 1. move aggregation to a SQL view with proper in-query authz so we
// can return a single row per workspace without this two-pass approach.
// 2. Restore the below router annotation and un-skip docs gen
// <at>Router /experimental/chats/by-workspace [post]
//
// @Summary Get latest chats by workspace IDs
// @ID get-latest-chats-by-workspace-ids
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Success 200
// @x-apidocgen {"skip": true}
func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idsParam := r.URL.Query().Get("workspace_ids")
if idsParam == "" {
httpapi.Write(ctx, rw, http.StatusOK, map[uuid.UUID]uuid.UUID{})
return
}
raw := strings.Split(idsParam, ",")
// maxWorkspaceIDs is coupled to DEFAULT_RECORDS_PER_PAGE (25) in
// site/src/components/PaginationWidget/utils.ts.
// If the page size changes, this limit should too.
const maxWorkspaceIDs = 25
if len(raw) > maxWorkspaceIDs {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Too many workspace IDs, maximum is %d.", maxWorkspaceIDs),
})
return
}
workspaceIDs := make([]uuid.UUID, 0, len(raw))
for _, s := range raw {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid workspace ID %q: %s", s, err),
})
return
}
workspaceIDs = append(workspaceIDs, id)
}
chats, err := api.Database.GetChatsByWorkspaceIDs(ctx, workspaceIDs)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chats by workspace.",
Detail: err.Error(),
})
return
}
// The SQL orders by (workspace_id, updated_at DESC), so the first
// chat seen per workspace after RBAC filtering is the latest
// readable one.
result := make(map[uuid.UUID]uuid.UUID, len(chats))
for _, chat := range chats {
if chat.WorkspaceID.Valid {
if _, exists := result[chat.WorkspaceID.UUID]; !exists {
result[chat.WorkspaceID.UUID] = chat.ID
}
}
}
httpapi.Write(ctx, rw, http.StatusOK, result)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -231,7 +336,7 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
LimitOpt: int32(paginationParams.Limit),
}
chats, err := api.Database.GetChats(ctx, params)
chatRows, err := api.Database.GetChats(ctx, params)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chats.",
@@ -240,7 +345,13 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
return
}
diffStatusesByChatID, err := api.getChatDiffStatusesByChatID(ctx, chats)
// Extract the Chat objects for diff status lookup.
dbChats := make([]database.Chat, len(chatRows))
for i, row := range chatRows {
dbChats[i] = row.Chat
}
diffStatusesByChatID, err := api.getChatDiffStatusesByChatID(ctx, dbChats)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chats.",
@@ -249,7 +360,7 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertChats(chats, diffStatusesByChatID))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatRows(chatRows, diffStatusesByChatID))
}
func (api *API) getChatDiffStatusesByChatID(
@@ -282,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
@@ -387,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(),
@@ -394,7 +514,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusCreated, convertChat(chat, nil))
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Chat(chat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -505,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
}
@@ -515,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
}
@@ -525,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
}
@@ -1111,15 +1243,23 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
diffStatus, err := api.resolveChatDiffStatus(ctx, chat)
if err != nil {
// Log but don't fail - diff status is supplementary.
api.Logger.Error(ctx, "failed to resolve chat diff status",
// Use the cached diff status from the database rather than
// resolving it inline. Inline resolution calls out to the
// git provider API (e.g. GitHub) on every request which
// blocks the response for 200-800ms. The background gitsync
// worker keeps the cached status fresh.
var diffStatus *database.ChatDiffStatus
status, err := api.Database.GetChatDiffStatusByChatID(ctx, chat.ID)
switch {
case err == nil:
diffStatus = &status
case !xerrors.Is(err, sql.ErrNoRows):
api.Logger.Error(ctx, "failed to get cached chat diff status",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, diffStatus))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, diffStatus))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -1450,8 +1590,8 @@ func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
logger.Debug(ctx, "desktop Bicopy finished")
}
// patchChat updates a chat resource. Supports updating labels and
// toggling the archived state.
// patchChat updates a chat resource. Supports updating labels,
// archiving, pinning, and pinned-chat ordering.
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
@@ -1509,20 +1649,20 @@ 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)
} else {
err = api.Database.ArchiveChatByID(ctx, chat.ID)
_, err = api.Database.ArchiveChatByID(ctx, chat.ID)
}
} else {
if api.chatDaemon != nil {
err = api.chatDaemon.UnarchiveChat(ctx, chat)
} else {
err = api.Database.UnarchiveChatByID(ctx, chat.ID)
_, err = api.Database.UnarchiveChatByID(ctx, chat.ID)
}
}
if err != nil {
@@ -1538,6 +1678,54 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
}
}
if req.PinOrder != nil {
pinOrder := *req.PinOrder
if pinOrder < 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Pin order must be non-negative.",
})
return
}
if pinOrder > 0 && chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin an archived chat.",
})
return
}
// The behavior depends on current pin state:
// - pinOrder == 0: unpin.
// - pinOrder > 0 && already pinned: reorder (shift
// neighbors, clamp to [1, count]).
// - pinOrder > 0 && not pinned: append to end. The
// requested value is intentionally ignored because
// PinChatByID also bumps updated_at to keep the
// chat visible in the paginated sidebar.
var err error
errMsg := "Failed to pin chat."
switch {
case pinOrder == 0:
errMsg = "Failed to unpin chat."
err = api.Database.UnpinChatByID(ctx, chat.ID)
case chat.PinOrder > 0:
errMsg = "Failed to reorder pinned chat."
err = api.Database.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{
ID: chat.ID,
PinOrder: pinOrder,
})
default:
err = api.Database.PinChatByID(ctx, chat.ID)
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: errMsg,
Detail: err.Error(),
})
return
}
}
rw.WriteHeader(http.StatusNoContent)
}
@@ -1794,6 +1982,39 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request
httpapi.Write(ctx, rw, http.StatusOK, convertChatMessage(promoteResult.PromotedMessage))
}
// markChatAsRead updates the last read message ID for a chat to the
// latest message, so subsequent unread checks treat all current
// messages as seen. This is called on stream connect and disconnect
// to avoid per-message API calls during active streaming.
func (api *API) markChatAsRead(ctx context.Context, chatID uuid.UUID) {
lastMsg, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chatID,
Role: database.ChatMessageRoleAssistant,
})
if errors.Is(err, sql.ErrNoRows) {
// No assistant messages yet, nothing to mark as read.
return
}
if err != nil {
api.Logger.Warn(ctx, "failed to get last assistant message for read marker",
slog.F("chat_id", chatID),
slog.Error(err),
)
return
}
err = api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chatID,
LastReadMessageID: lastMsg.ID,
})
if err != nil {
api.Logger.Warn(ctx, "failed to update chat last read message ID",
slog.F("chat_id", chatID),
slog.Error(err),
)
}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -1850,6 +2071,12 @@ func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
}()
defer cancel()
// Mark the chat as read when the stream connects and again
// when it disconnects so we avoid per-message API calls while
// messages are actively streaming.
api.markChatAsRead(ctx, chatID)
defer api.markChatAsRead(context.WithoutCancel(ctx), chatID)
sendChatStreamBatch := func(batch []codersdk.ChatStreamEvent) error {
if len(batch) == 0 {
return nil
@@ -1949,7 +2176,51 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
chat = updatedChat
}
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
Detail: "Chat processor is not configured.",
})
return
}
updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrManualTitleRegenerationInProgress) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Title regeneration already in progress for this chat.",
})
return
}
if maybeWriteLimitErr(ctx, rw, err) {
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to regenerate chat title.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -2095,68 +2366,6 @@ func chatWorkspaceAuditStatus(err error) int {
return http.StatusInternalServerError
}
func (api *API) resolveChatDiffStatus(
ctx context.Context,
chat database.Chat,
) (*database.ChatDiffStatus, error) {
status, found, err := api.getCachedChatDiffStatus(ctx, chat.ID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
reference, err := api.resolveChatDiffReference(ctx, chat, found, status)
if err != nil {
return nil, err
}
if reference.PullRequestURL != "" {
if !found || !strings.EqualFold(strings.TrimSpace(status.Url.String), reference.PullRequestURL) {
status, err = api.upsertChatDiffStatusReference(ctx, chat.ID, reference.PullRequestURL, now.Add(-time.Second))
if err != nil {
return nil, err
}
found = true
}
}
if !found {
return nil, nil //nolint:nilnil // Callers handle nil status explicitly.
}
if !chatDiffStatusIsStale(status, now) {
return &status, nil
}
// Use the same refresh pipeline as the background worker
// so both paths share identical provider/token resolution.
refreshed, err := api.gitSyncWorker.RefreshChat(
ctx, status, chat.OwnerID,
)
if err == nil && refreshed != nil {
return refreshed, nil
}
if err == nil {
// No PR exists yet; return what we have.
return &status, nil
}
api.Logger.Warn(ctx, "failed to refresh chat diff status",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
backoffStatus, backoffErr := api.upsertChatDiffStatusReference(ctx, chat.ID, reference.PullRequestURL, now.Add(chatDiffStatusTTL))
if backoffErr != nil {
api.Logger.Warn(ctx, "failed to extend chat diff status stale timestamp",
slog.F("chat_id", chat.ID),
slog.Error(backoffErr),
)
return &status, nil
}
return &backoffStatus, nil
}
func (api *API) resolveChatDiffContents(
ctx context.Context,
chat database.Chat,
@@ -2423,13 +2632,6 @@ func (api *API) resolveGitProvider(origin string) gitprovider.Provider {
return gp
}
func chatDiffStatusIsStale(status database.ChatDiffStatus, now time.Time) bool {
if !status.RefreshedAt.Valid {
return true
}
return !status.StaleAt.After(now)
}
func (api *API) resolveChatGitAccessToken(
ctx context.Context,
userID uuid.UUID,
@@ -2680,25 +2882,35 @@ func detectChatFileType(data []byte) string {
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
prompt, err := api.Database.GetChatSystemPrompt(ctx)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
config, err := api.Database.GetChatSystemPromptConfig(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat system prompt.",
Message: "Internal error fetching chat system prompt configuration.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPrompt{
SystemPrompt: prompt,
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPromptResponse{
SystemPrompt: config.ChatSystemPrompt,
IncludeDefaultSystemPrompt: config.IncludeDefaultSystemPrompt,
DefaultSystemPrompt: chatd.DefaultSystemPrompt,
})
}
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes))
var req codersdk.ChatSystemPrompt
var req codersdk.UpdateChatSystemPromptRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
@@ -2712,13 +2924,23 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
})
return
}
err := api.Database.UpsertChatSystemPrompt(ctx, sanitizedPrompt)
if httpapi.Is404Error(err) { // also catches authz error
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
// Only update the include-default flag when the caller explicitly
// provides it. Omitting the field preserves whatever is currently
// stored (or the schema-level default for new deployments),
// avoiding a backward-compatibility regression for older clients
// that only send system_prompt.
if req.IncludeDefaultSystemPrompt != nil {
return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt)
}
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt.",
Message: "Internal error updating chat system prompt configuration.",
Detail: err.Error(),
})
return
@@ -3052,6 +3274,8 @@ func (api *API) putUserChatCustomPrompt(rw http.ResponseWriter, r *http.Request)
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventUserPrompt, apiKey.UserID)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCustomPrompt{
CustomPrompt: updatedConfig.Value,
})
@@ -3222,21 +3446,32 @@ func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *htt
}
func (api *API) resolvedChatSystemPrompt(ctx context.Context) string {
custom, err := api.Database.GetChatSystemPrompt(ctx)
config, err := api.Database.GetChatSystemPromptConfig(ctx)
if err != nil {
// Log but don't fail chat creation — fall back to the
// built-in default so the user isn't blocked.
api.Logger.Error(ctx, "failed to fetch custom chat system prompt, using default", slog.Error(err))
// We intentionally fail open here. When the prompt configuration
// cannot be read, returning the built-in default keeps the chat
// grounded instead of sending no system guidance at all.
api.Logger.Error(ctx, "failed to fetch chat system prompt configuration, using default", slog.Error(err))
return chatd.DefaultSystemPrompt
}
sanitized := chatd.SanitizePromptText(custom)
if sanitized == "" && strings.TrimSpace(custom) != "" {
api.Logger.Warn(ctx, "custom system prompt became empty after sanitization, using default")
sanitizedCustom := chatd.SanitizePromptText(config.ChatSystemPrompt)
if sanitizedCustom == "" && strings.TrimSpace(config.ChatSystemPrompt) != "" {
api.Logger.Warn(ctx, "custom system prompt became empty after sanitization, omitting custom portion")
}
if sanitized != "" {
return sanitized
var parts []string
if config.IncludeDefaultSystemPrompt {
parts = append(parts, chatd.DefaultSystemPrompt)
}
return chatd.DefaultSystemPrompt
if sanitizedCustom != "" {
parts = append(parts, sanitizedCustom)
}
result := strings.Join(parts, "\n\n")
if result == "" {
api.Logger.Warn(ctx, "resolved system prompt is empty, no system prompt will be injected into chats")
}
return result
}
func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
@@ -3558,73 +3793,6 @@ func truncateRunes(value string, maxLen int) string {
return string(runes[:maxLen])
}
func convertChat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
mcpServerIDs := c.MCPServerIDs
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
}
labels := map[string]string(c.Labels)
if labels == nil {
labels = map[string]string{}
}
chat := codersdk.Chat{
ID: c.ID,
OwnerID: c.OwnerID,
LastModelConfigID: c.LastModelConfigID,
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
Archived: c.Archived,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
MCPServerIDs: mcpServerIDs,
Labels: labels,
}
if c.LastError.Valid {
chat.LastError = &c.LastError.String
}
if c.ParentChatID.Valid {
parentChatID := c.ParentChatID.UUID
chat.ParentChatID = &parentChatID
}
switch {
case c.RootChatID.Valid:
rootChatID := c.RootChatID.UUID
chat.RootChatID = &rootChatID
case c.ParentChatID.Valid:
rootChatID := c.ParentChatID.UUID
chat.RootChatID = &rootChatID
default:
rootChatID := c.ID
chat.RootChatID = &rootChatID
}
if c.WorkspaceID.Valid {
chat.WorkspaceID = &c.WorkspaceID.UUID
}
if diffStatus != nil {
convertedDiffStatus := db2sdk.ChatDiffStatus(c.ID, diffStatus)
chat.DiffStatus = &convertedDiffStatus
}
return chat
}
func convertChats(chats []database.Chat, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
result := make([]codersdk.Chat, len(chats))
for i, c := range chats {
diffStatus, ok := diffStatusesByChatID[c.ID]
if ok {
result[i] = convertChat(c, &diffStatus)
continue
}
result[i] = convertChat(c, nil)
if diffStatusesByChatID != nil {
emptyDiffStatus := db2sdk.ChatDiffStatus(c.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
}
}
return result
}
func convertChatCostModelBreakdown(model database.GetChatCostPerModelRow) codersdk.ChatCostModelBreakdown {
displayName := strings.TrimSpace(model.DisplayName)
if displayName == "" {
@@ -3880,6 +4048,8 @@ func (api *API) createChatProvider(rw http.ResponseWriter, r *http.Request) {
}
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventProviders, uuid.Nil)
httpapi.Write(
ctx,
rw,
@@ -3966,6 +4136,8 @@ func (api *API) updateChatProvider(rw http.ResponseWriter, r *http.Request) {
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventProviders, uuid.Nil)
httpapi.Write(
ctx,
rw,
@@ -4020,6 +4192,8 @@ func (api *API) deleteChatProvider(rw http.ResponseWriter, r *http.Request) {
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventProviders, uuid.Nil)
rw.WriteHeader(http.StatusNoContent)
}
@@ -4199,6 +4373,8 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
}
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, inserted.ID)
httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted))
}
@@ -4370,6 +4546,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
}
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, updated.ID)
httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated))
}
@@ -4410,6 +4588,8 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) {
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, modelConfigID)
rw.WriteHeader(http.StatusNoContent)
}
+1487 -165
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -71,8 +71,8 @@ func (r *ProvisionerDaemonsReport) Run(ctx context.Context, opts *ProvisionerDae
return
}
// nolint: gocritic // need an actor to fetch provisioner daemons
daemons, err := opts.Store.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx))
// nolint: gocritic // Read-only access to provisioner daemons for health check
daemons, err := opts.Store.GetProvisionerDaemons(dbauthz.AsSystemReadProvisionerDaemons(ctx))
if err != nil {
r.Severity = health.SeverityError
r.Error = ptr.Ref("error fetching provisioner daemons: " + err.Error())
+11 -1
View File
@@ -438,7 +438,7 @@ func OneWayWebSocketEventSender(log slog.Logger) func(rw http.ResponseWriter, r
}
go HeartbeatClose(ctx, log, cancel, socket)
eventC := make(chan codersdk.ServerSentEvent)
eventC := make(chan codersdk.ServerSentEvent, 64)
socketErrC := make(chan websocket.CloseError, 1)
closed := make(chan struct{})
go func() {
@@ -488,6 +488,16 @@ func OneWayWebSocketEventSender(log slog.Logger) func(rw http.ResponseWriter, r
}()
sendEvent := func(event codersdk.ServerSentEvent) error {
// Prioritize context cancellation over sending to the
// buffered channel. Without this check, both cases in
// the select below can fire simultaneously when the
// context is already done and the channel has capacity,
// making the result nondeterministic.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
select {
case eventC <- event:
case <-ctx.Done():
+2 -2
View File
@@ -699,8 +699,8 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
// is being used with the correct audience/resource server (RFC 8707).
func validateOAuth2ProviderAppTokenAudience(ctx context.Context, db database.Store, key database.APIKey, accessURL *url.URL, r *http.Request) error {
// Get the OAuth2 provider app token to check its audience
//nolint:gocritic // System needs to access token for audience validation
token, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemRestricted(ctx), key.ID)
//nolint:gocritic // OAuth2 system context — audience validation for provider app tokens
token, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx), key.ID)
if err != nil {
return xerrors.Errorf("failed to get OAuth2 token: %w", err)
}
+1
View File
@@ -62,6 +62,7 @@ func TestChatParam(t *testing.T) {
require.NoError(t, err)
chat, err := db.InsertChat(context.Background(), database.InsertChatParams{
Status: database.ChatStatusWaiting,
OwnerID: ownerID,
WorkspaceID: uuid.NullUUID{},
ParentChatID: uuid.NullUUID{},
-1
View File
@@ -73,7 +73,6 @@ func CSRF(cookieCfg codersdk.HTTPCookieConfig) func(next http.Handler) http.Hand
// CSRF only affects requests that automatically attach credentials via a cookie.
// If no cookie is present, then there is no risk of CSRF.
//nolint:govet
sessCookie, err := r.Cookie(codersdk.SessionTokenCookie)
if xerrors.Is(err, http.ErrNoCookie) {
return true
+95 -10
View File
@@ -25,6 +25,7 @@ import (
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/x/chatd/mcpclient"
"github.com/coder/coder/v2/codersdk"
)
@@ -59,7 +60,8 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) {
}
// Look up the calling user's OAuth2 tokens so we can populate
// auth_connected per server.
// auth_connected per server. Attempt to refresh expired tokens
// so the status is accurate and the token is ready for use.
//nolint:gocritic // Need to check user tokens across all servers.
userTokens, err := api.Database.GetMCPServerUserTokensByUserID(dbauthz.AsSystemRestricted(ctx), apiKey.UserID)
if err != nil {
@@ -69,9 +71,20 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) {
})
return
}
// Build a config lookup for the refresh helper.
configByID := make(map[uuid.UUID]database.MCPServerConfig, len(configs))
for _, c := range configs {
configByID[c.ID] = c
}
tokenMap := make(map[uuid.UUID]bool, len(userTokens))
for _, t := range userTokens {
tokenMap[t.MCPServerConfigID] = true
for _, tok := range userTokens {
cfg, ok := configByID[tok.MCPServerConfigID]
if !ok {
continue
}
tokenMap[tok.MCPServerConfigID] = api.refreshMCPUserToken(ctx, cfg, tok, apiKey.UserID)
}
resp := make([]codersdk.MCPServerConfig, 0, len(configs))
@@ -157,6 +170,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)),
Availability: strings.TrimSpace(req.Availability),
Enabled: req.Enabled,
ModelIntent: req.ModelIntent,
CreatedBy: apiKey.UserID,
UpdatedBy: apiKey.UserID,
})
@@ -243,6 +257,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ToolDenyList: inserted.ToolDenyList,
Availability: inserted.Availability,
Enabled: inserted.Enabled,
ModelIntent: inserted.ModelIntent,
UpdatedBy: apiKey.UserID,
})
if err != nil {
@@ -310,6 +325,7 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ToolDenyList: coalesceStringSlice(trimStringSlice(req.ToolDenyList)),
Availability: strings.TrimSpace(req.Availability),
Enabled: req.Enabled,
ModelIntent: req.ModelIntent,
CreatedBy: apiKey.UserID,
UpdatedBy: apiKey.UserID,
})
@@ -386,7 +402,8 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
sdkConfig = convertMCPServerConfigRedacted(config)
}
// Populate AuthConnected for the calling user.
// Populate AuthConnected for the calling user. Attempt to
// refresh the token so the status is accurate.
if config.AuthType == "oauth2" {
//nolint:gocritic // Need to check user token for this server.
userTokens, err := api.Database.GetMCPServerUserTokensByUserID(dbauthz.AsSystemRestricted(ctx), apiKey.UserID)
@@ -397,9 +414,9 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
})
return
}
for _, t := range userTokens {
if t.MCPServerConfigID == config.ID {
sdkConfig.AuthConnected = true
for _, tok := range userTokens {
if tok.MCPServerConfigID == config.ID {
sdkConfig.AuthConnected = api.refreshMCPUserToken(ctx, config, tok, apiKey.UserID)
break
}
}
@@ -558,6 +575,11 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
enabled = *req.Enabled
}
modelIntent := existing.ModelIntent
if req.ModelIntent != nil {
modelIntent = *req.ModelIntent
}
// When auth_type changes, clear fields belonging to the
// previous auth type so stale secrets don't persist.
if authType != existing.AuthType {
@@ -625,6 +647,7 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ToolDenyList: toolDenyList,
Availability: availability,
Enabled: enabled,
ModelIntent: modelIntent,
UpdatedBy: apiKey.UserID,
ID: existing.ID,
})
@@ -1002,6 +1025,67 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques
// parseMCPServerConfigID extracts the MCP server config UUID from the
// "mcpServer" path parameter.
// refreshMCPUserToken attempts to refresh an expired OAuth2 token
// for the given MCP server config. Returns true when the token is
// valid (either still fresh or successfully refreshed), false when
// the token is expired and cannot be refreshed.
func (api *API) refreshMCPUserToken(
ctx context.Context,
cfg database.MCPServerConfig,
tok database.MCPServerUserToken,
userID uuid.UUID,
) bool {
if cfg.AuthType != "oauth2" {
return true
}
if tok.RefreshToken == "" {
// No refresh token — consider connected only if not
// expired (or no expiry set).
return !tok.Expiry.Valid || tok.Expiry.Time.After(time.Now())
}
result, err := mcpclient.RefreshOAuth2Token(ctx, cfg, tok)
if err != nil {
api.Logger.Warn(ctx, "failed to refresh MCP oauth2 token",
slog.F("server_slug", cfg.Slug),
slog.Error(err),
)
// Refresh failed — token is dead.
return false
}
if result.Refreshed {
var expiry sql.NullTime
if !result.Expiry.IsZero() {
expiry = sql.NullTime{Time: result.Expiry, Valid: true}
}
//nolint:gocritic // Need system-level write access to
// persist the refreshed OAuth2 token.
_, err = api.Database.UpsertMCPServerUserToken(
dbauthz.AsSystemRestricted(ctx),
database.UpsertMCPServerUserTokenParams{
MCPServerConfigID: tok.MCPServerConfigID,
UserID: userID,
AccessToken: result.AccessToken,
AccessTokenKeyID: sql.NullString{},
RefreshToken: result.RefreshToken,
RefreshTokenKeyID: sql.NullString{},
TokenType: result.TokenType,
Expiry: expiry,
},
)
if err != nil {
api.Logger.Warn(ctx, "failed to persist refreshed MCP oauth2 token",
slog.F("server_slug", cfg.Slug),
slog.Error(err),
)
}
}
return true
}
func parseMCPServerConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
mcpServerID, err := uuid.Parse(chi.URLParam(r, "mcpServer"))
if err != nil {
@@ -1045,9 +1129,10 @@ func convertMCPServerConfig(config database.MCPServerConfig) codersdk.MCPServerC
Availability: config.Availability,
Enabled: config.Enabled,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
Enabled: config.Enabled,
ModelIntent: config.ModelIntent,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
}
}
+62 -4
View File
@@ -2,6 +2,7 @@ package coderd
import (
"context"
"database/sql"
"fmt"
"net/http"
@@ -179,7 +180,17 @@ func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) {
return
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows)
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, []uuid.UUID{member.UserID})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows, aiSeatSet)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -227,7 +238,21 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) {
return
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members)
userIDs := make([]uuid.UUID, 0, len(members))
for _, member := range members {
userIDs = append(userIDs, member.OrganizationMember.UserID)
}
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members, aiSeatSet)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -324,7 +349,21 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
return
}
members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows)
userIDs := make([]uuid.UUID, 0, len(memberRows))
for _, member := range memberRows {
userIDs = append(userIDs, member.OrganizationMember.UserID)
}
var aiSeatSet map[uuid.UUID]struct{}
if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) {
//nolint:gocritic // AI seat state is a system-level read gated by entitlement.
aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
}
members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows, aiSeatSet)
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -337,6 +376,23 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func getAISeatSetByUserIDs(ctx context.Context, db database.Store, userIDs []uuid.UUID) (map[uuid.UUID]struct{}, error) {
aiSeatUserIDs, err := db.GetUserAISeatStates(ctx, userIDs)
if xerrors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
return nil, err
}
aiSeatSet := make(map[uuid.UUID]struct{}, len(aiSeatUserIDs))
for _, uid := range aiSeatUserIDs {
aiSeatSet[uid] = struct{}{}
}
return aiSeatSet, nil
}
// @Summary Assign role to organization member
// @ID assign-role-to-organization-member
// @Security CoderSessionToken
@@ -508,7 +564,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d
return converted, nil
}
func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithUserData, error) {
func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow, aiSeatSet map[uuid.UUID]struct{}) ([]codersdk.OrganizationMemberWithUserData, error) {
members := make([]database.OrganizationMember, 0)
for _, row := range rows {
members = append(members, row.OrganizationMember)
@@ -524,12 +580,14 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto
converted := make([]codersdk.OrganizationMemberWithUserData, 0)
for i := range convertedMembers {
_, hasAISeat := aiSeatSet[rows[i].OrganizationMember.UserID]
converted = append(converted, codersdk.OrganizationMemberWithUserData{
Username: rows[i].Username,
AvatarURL: rows[i].AvatarURL,
Name: rows[i].Name,
Email: rows[i].Email,
GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles),
HasAISeat: hasAISeat,
LastSeenAt: rows[i].LastSeenAt,
Status: codersdk.UserStatus(rows[i].Status),
IsServiceAccount: rows[i].IsServiceAccount,

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