Compare commits

..

1 Commits

Author SHA1 Message Date
Kyle Carberry 56ff5dfded feat(coderd/database): add automations database schema and queries
Adds the database layer for the chat automations feature:

Schema (migration 000454):
- chat_automations table with status, rate limits, instructions, MCP config
- chat_automation_triggers table (webhook + cron types)
- chat_automation_events table for audit trail
- Indexes for performance and unique constraint on (owner, org, name)
- automation_id FK on chats table

SQL queries:
- chatautomations.sql: CRUD with authorize_filter support
- chatautomationtriggers.sql: CRUD + GetActiveChatAutomationCronTriggers JOIN query
- chatautomationevents.sql: insert, list, count windows, purge

RBAC:
- chat_automation resource with CRUD actions
- dbauthz wrappers for all 18 query methods

Also adds LockIDChatAutomationCron advisory lock constant.
2026-04-01 14:10:38 +00:00
486 changed files with 13323 additions and 32827 deletions
+4 -6
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`: reference `.agents/skills/deep-review/references/typescript.md` before reviewing.
- React: `*.tsx` `*.jsx`: reference `.agents/skills/deep-review/references/react.md` before reviewing.
- TypeScript: `*.ts` `*.tsx`
- React: `*.tsx` `*.jsx`
`.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,11 +155,9 @@ File scope: {filter from step 2}.
Output file: {REVIEW_DIR}/{role-name}.md
```
For Modernization Reviewer instances, add the language reference after the methodology line:
For the Modernization Reviewer (Go), add after the methodology line:
- **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.`
> Read `.claude/docs/GO.md` as your Go language reference before reviewing.
For re-reviews, append to both Tier 1 and Tier 2 prompts:
@@ -1,305 +0,0 @@
# 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.
@@ -1,199 +0,0 @@
# 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.
+3
View File
@@ -82,6 +82,9 @@ updates:
mui:
patterns:
- "@mui*"
radix:
patterns:
- "@radix-ui/*"
react:
patterns:
- "react"
+20 -20
View File
@@ -35,7 +35,7 @@ jobs:
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -157,7 +157,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -204,7 +204,7 @@ jobs:
# Needed for helm chart linting
- name: Install helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with:
version: v3.9.2
continue-on-error: true
@@ -247,7 +247,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -272,7 +272,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -327,7 +327,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -379,7 +379,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -575,7 +575,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -637,7 +637,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -709,7 +709,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -736,7 +736,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -769,7 +769,7 @@ jobs:
name: ${{ matrix.variant.name }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -849,7 +849,7 @@ jobs:
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -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@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
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@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
env:
NODE_OPTIONS: "--max_old_space_size=4096"
STORYBOOK: true
@@ -930,7 +930,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1005,7 +1005,7 @@ jobs:
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1043,7 +1043,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1097,7 +1097,7 @@ jobs:
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1479,7 +1479,7 @@ jobs:
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v3.0.0
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -65,7 +65,7 @@ jobs:
packages: write # to retag image as dogfood
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -142,7 +142,7 @@ jobs:
needs: deploy
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -125,7 +125,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+41 -8
View File
@@ -5,6 +5,8 @@ on:
branches:
- main
- "release/2.[0-9]+"
release:
types: [published]
permissions:
contents: read
@@ -27,9 +29,9 @@ jobs:
- 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.
# 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)
@@ -38,10 +40,8 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
NEXT="2.$((LATEST_MINOR + 1))"
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "version=2.$((LATEST_MINOR + 1))" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Detected next release: $NEXT"
- name: Sync issues
id: sync
@@ -51,7 +51,6 @@ jobs:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: sync
version: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
timeout: 300
sync-release-branch:
@@ -77,7 +76,6 @@ jobs:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: sync
version: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
timeout: 300
code-freeze:
@@ -108,3 +106,38 @@ jobs:
version: ${{ steps.version.outputs.version }}
timeout: 300
complete:
name: Complete Linear release
if: github.event_name == 'release'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Extract release version
id: version
# Strip "v" prefix and patch: "v2.31.0" -> "2.31". Also detect whether
# this is a minor release (v*.*.0) — patch releases (v2.31.1, v2.31.2,
# ...) are grouped into the same Linear release and must not re-complete
# it after it has already shipped.
run: |
VERSION=$(echo "$TAG" | sed 's/^v//' | cut -d. -f1,2)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then
echo "is_minor=true" >> "$GITHUB_OUTPUT"
else
echo "is_minor=false" >> "$GITHUB_OUTPUT"
fi
env:
TAG: ${{ github.event.release.tag_name }}
- name: Complete release
id: complete
if: steps.version.outputs.is_minor == 'true'
uses: linear/linear-release-action@755d50b5adb7dd42b976ee9334952745d62ceb2d # v0.6.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ steps.version.outputs.version }}
timeout: 300
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+5 -5
View File
@@ -39,7 +39,7 @@ jobs:
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -76,7 +76,7 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -184,7 +184,7 @@ jobs:
pull-requests: write # needed for commenting on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -228,7 +228,7 @@ jobs:
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -288,7 +288,7 @@ jobs:
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+7 -50
View File
@@ -9,7 +9,6 @@ on:
options:
- mainline
- stable
- rc
release_notes:
description: Release notes for the publishing the release. This is required to create a release.
dry_run:
@@ -81,7 +80,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -120,19 +119,9 @@ jobs:
exit 1
fi
# 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
# 2.10.2 -> release/2.10
version="$(./scripts/version.sh)"
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
release_branch=release/${version%.*}
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?"
@@ -542,9 +531,6 @@ 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
@@ -577,35 +563,6 @@ 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:
@@ -657,7 +614,7 @@ jobs:
retention-days: 7
- name: Send repository-dispatch event
if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }}
if: ${{ !inputs.dry_run }}
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
@@ -673,7 +630,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -745,11 +702,11 @@ jobs:
name: Publish to winget-pkgs
runs-on: windows-latest
needs: release
if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }}
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -47,6 +47,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
with:
sarif_file: results.sarif
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -40,7 +40,7 @@ jobs:
uses: ./.github/actions/setup-go
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
with:
languages: go, javascript
@@ -50,7 +50,7 @@ jobs:
rm Makefile
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
- name: Send Slack notification on failure
if: ${{ failure() }}
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -96,7 +96,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -120,7 +120,7 @@ jobs:
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+3 -5
View File
@@ -21,7 +21,7 @@ jobs:
pull-requests: write # required to post PR review comments by the action
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -49,10 +49,8 @@ jobs:
# 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: Enable corepack
run: corepack enable pnpm
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@37c85bcde51b30bf929936502bac6bfb7e8f0a4d # v1.4.1
-3
View File
@@ -110,9 +110,6 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
- For experimental or unstable API paths, skip public doc generation with
`// @x-apidocgen {"skip": true}` after the `@Router` annotation. This
keeps them out of the published API reference until they stabilize.
- Experimental chat endpoints in `coderd/exp_chats.go` omit swagger
annotations entirely. Do not add `@Summary`, `@Router`, or other
swagger comments to handlers in that file.
### Database Query Naming
-1
View File
@@ -988,7 +988,6 @@ 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
+8 -16
View File
@@ -38,7 +38,6 @@ 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"
@@ -309,13 +308,12 @@ type agent struct {
containerAPI *agentcontainers.API
gitAPIOptions []agentgit.Option
filesAPI *agentfiles.API
gitAPI *agentgit.API
processAPI *agentproc.API
desktopAPI *agentdesktop.API
mcpManager *agentmcp.Manager
mcpAPI *agentmcp.API
contextConfigAPI *agentcontextconfig.API
filesAPI *agentfiles.API
gitAPI *agentgit.API
processAPI *agentproc.API
desktopAPI *agentdesktop.API
mcpManager *agentmcp.Manager
mcpAPI *agentmcp.API
socketServerEnabled bool
socketPath string
@@ -398,17 +396,11 @@ func (a *agent) init() {
gitOpts := append([]agentgit.Option{agentgit.WithClock(a.clock)}, a.gitAPIOptions...)
a.gitAPI = agentgit.NewAPI(a.logger.Named("git"), pathStore, gitOpts...)
desktop := agentdesktop.NewPortableDesktop(
a.logger.Named("desktop"), a.execer, a.scriptRunner.ScriptBinDir(), nil,
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,
@@ -1366,7 +1358,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
// 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.MCPConfigFiles()); mcpErr != nil {
if mcpErr := a.mcpManager.Connect(a.gracefulCtx, manifest.Directory); mcpErr != nil {
a.logger.Warn(ctx, "failed to connect to workspace MCP servers", slog.Error(mcpErr))
}
})
-52
View File
@@ -1,8 +1,6 @@
package agent
import (
"path/filepath"
"runtime"
"testing"
"github.com/google/uuid"
@@ -10,22 +8,10 @@ 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) {
@@ -56,41 +42,3 @@ 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 ""
})
mcpFiles1 := a.contextConfigAPI.MCPConfigFiles()
require.NotEmpty(t, mcpFiles1)
require.Contains(t, mcpFiles1[0], dir1)
// Simulate manifest update on reconnection -- no field
// reassignment needed, the lazy closure picks it up.
a.manifest.Store(&agentsdk.Manifest{Directory: dir2})
mcpFiles2 := a.contextConfigAPI.MCPConfigFiles()
require.NotEmpty(t, mcpFiles2)
require.Contains(t, mcpFiles2[0], dir2)
}
+8 -15
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.WaitLong)
ctx := testutil.Context(t, testutil.WaitShort)
logger := testutil.Logger(t)
// After the agent is disconnected from a coordinator, it's supposed
// to reconnect!
@@ -3020,8 +3020,7 @@ func TestAgent_Reconnect(t *testing.T) {
logger,
agentID,
agentsdk.Manifest{
DERPMap: derpMap,
Directory: "/test/workspace",
DERPMap: derpMap,
},
statsCh,
fCoordinator,
@@ -3034,19 +3033,13 @@ func TestAgent_Reconnect(t *testing.T) {
})
defer closer.Close()
// 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.
call1 := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
require.Equal(t, client.GetNumRefreshTokenCalls(), 1)
close(call1.Resps) // hang up
// expect reconnect
testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
require.Equal(t, reconnections+1, client.GetNumRefreshTokenCalls())
// Check that the agent refreshes the token when it reconnects.
require.Equal(t, client.GetNumRefreshTokenCalls(), 2)
closer.Close()
}
-313
View File
@@ -1,313 +0,0 @@
package agentcontextconfig
import (
"cmp"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"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"
)
const (
maxInstructionFileBytes = 64 * 1024
maxSkillMetaBytes = 64 * 1024
)
// markdownCommentPattern strips HTML comments from instruction
// file content for security (prevents hidden prompt injection).
var markdownCommentPattern = regexp.MustCompile(`<!--[\s\S]*?-->`)
// invisibleRunePattern strips invisible Unicode characters that
// could be used for prompt injection.
//
//nolint:gocritic // Non-ASCII char ranges are intentional for invisible Unicode stripping.
var invisibleRunePattern = regexp.MustCompile(
"[\u00ad\u034f\u061c\u070f" +
"\u115f\u1160\u17b4\u17b5" +
"\u180b-\u180f" +
"\u200b\u200d\u200e\u200f" +
"\u202a-\u202e" +
"\u2060-\u206f" +
"\u3164" +
"\ufe00-\ufe0f" +
"\ufeff" +
"\uffa0" +
"\ufff0-\ufff8]",
)
// skillNamePattern validates kebab-case skill names.
var skillNamePattern = regexp.MustCompile(
`^[a-z0-9]+(-[a-z0-9]+)*$`,
)
// Default values for agent-internal configuration. These are
// used when the corresponding env vars are unset.
const (
DefaultInstructionsDir = "~/.coder"
DefaultInstructionsFile = "AGENTS.md"
DefaultSkillsDir = ".agents/skills"
DefaultSkillMetaFile = "SKILL.md"
DefaultMCPConfigFile = ".mcp.json"
)
// 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, resolves paths, reads instruction files,
// and discovers skills. Returns the HTTP response and the resolved
// MCP config file paths (used only agent-internally). Exported
// for use by tests.
func Config(workingDir string) (workspacesdk.ContextConfigResponse, []string) {
// 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)), DefaultInstructionsDir)
instructionsFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsFile)), DefaultInstructionsFile)
skillsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillsDirs)), DefaultSkillsDir)
skillMetaFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillMetaFile)), DefaultSkillMetaFile)
mcpConfigFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvMCPConfigFiles)), DefaultMCPConfigFile)
resolvedInstructionsDirs := ResolvePaths(instructionsDir, workingDir)
resolvedSkillsDirs := ResolvePaths(skillsDir, workingDir)
// Read instruction files from each configured directory.
parts := readInstructionFiles(resolvedInstructionsDirs, instructionsFile)
// Also check the working directory for the instruction file,
// unless it was already covered by InstructionsDirs.
if workingDir != "" {
seenDirs := make(map[string]struct{}, len(resolvedInstructionsDirs))
for _, d := range resolvedInstructionsDirs {
seenDirs[d] = struct{}{}
}
if _, ok := seenDirs[workingDir]; !ok {
if entry, found := readInstructionFileFromDir(workingDir, instructionsFile); found {
parts = append(parts, entry)
}
}
}
// Discover skills from each configured skills directory.
skillParts := discoverSkills(resolvedSkillsDirs, skillMetaFile)
parts = append(parts, skillParts...)
// Guarantee non-nil slice to signal agent support.
if parts == nil {
parts = []codersdk.ChatMessagePart{}
}
return workspacesdk.ContextConfigResponse{
Parts: parts,
}, ResolvePaths(mcpConfigFile, workingDir)
}
// MCPConfigFiles returns the resolved MCP configuration file
// paths for the agent's MCP manager.
func (api *API) MCPConfigFiles() []string {
_, mcpFiles := Config(api.workingDir())
return mcpFiles
}
// 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) {
response, _ := Config(api.workingDir())
httpapi.Write(r.Context(), rw, http.StatusOK, response)
}
// readInstructionFiles reads instruction files from each given
// directory. Missing directories are silently skipped. Duplicate
// directories are deduplicated.
func readInstructionFiles(dirs []string, fileName string) []codersdk.ChatMessagePart {
var parts []codersdk.ChatMessagePart
seen := make(map[string]struct{}, len(dirs))
for _, dir := range dirs {
if _, ok := seen[dir]; ok {
continue
}
seen[dir] = struct{}{}
if part, found := readInstructionFileFromDir(dir, fileName); found {
parts = append(parts, part)
}
}
return parts
}
// readInstructionFileFromDir scans a directory for a file matching
// fileName (case-insensitive) and reads its contents.
func readInstructionFileFromDir(dir, fileName string) (codersdk.ChatMessagePart, bool) {
dirEntries, err := os.ReadDir(dir)
if err != nil {
return codersdk.ChatMessagePart{}, false
}
for _, e := range dirEntries {
if e.IsDir() {
continue
}
if strings.EqualFold(strings.TrimSpace(e.Name()), fileName) {
filePath := filepath.Join(dir, e.Name())
content, truncated, ok := readAndSanitizeFile(filePath, maxInstructionFileBytes)
if !ok {
return codersdk.ChatMessagePart{}, false
}
if content == "" {
return codersdk.ChatMessagePart{}, false
}
return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: filePath,
ContextFileContent: content,
ContextFileTruncated: truncated,
}, true
}
}
return codersdk.ChatMessagePart{}, false
}
// readAndSanitizeFile reads the file at path, capping the read
// at maxBytes to avoid unbounded memory allocation. It sanitizes
// the content (strips HTML comments and invisible Unicode) and
// returns the result. Returns false if the file cannot be read.
func readAndSanitizeFile(path string, maxBytes int64) (content string, truncated bool, ok bool) {
f, err := os.Open(path)
if err != nil {
return "", false, false
}
defer f.Close()
// Read at most maxBytes+1 to detect truncation without
// allocating the entire file into memory.
raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
if err != nil {
return "", false, false
}
truncated = int64(len(raw)) > maxBytes
if truncated {
raw = raw[:maxBytes]
}
s := sanitizeInstructionMarkdown(string(raw))
if s == "" {
return "", truncated, true
}
return s, truncated, true
}
// sanitizeInstructionMarkdown strips HTML comments, invisible
// Unicode characters, and CRLF line endings from instruction
// file content.
func sanitizeInstructionMarkdown(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = markdownCommentPattern.ReplaceAllString(content, "")
content = invisibleRunePattern.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
// discoverSkills walks the given skills directories and returns
// metadata for every valid skill it finds. Body and supporting
// file lists are NOT included; chatd fetches those on demand
// via read_skill. Missing directories or individual errors are
// silently skipped.
func discoverSkills(skillsDirs []string, metaFile string) []codersdk.ChatMessagePart {
seen := make(map[string]struct{})
var parts []codersdk.ChatMessagePart
for _, skillsDir := range skillsDirs {
entries, err := os.ReadDir(skillsDir)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
metaPath := filepath.Join(skillsDir, entry.Name(), metaFile)
f, err := os.Open(metaPath)
if err != nil {
continue
}
raw, err := io.ReadAll(io.LimitReader(f, maxSkillMetaBytes+1))
_ = f.Close()
if err != nil {
continue
}
if int64(len(raw)) > maxSkillMetaBytes {
raw = raw[:maxSkillMetaBytes]
}
name, description, _, err := workspacesdk.ParseSkillFrontmatter(string(raw))
if err != nil {
continue
}
// The directory name must match the declared name.
if name != entry.Name() {
continue
}
if !skillNamePattern.MatchString(name) {
continue
}
// First occurrence wins across directories.
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
skillDir := filepath.Join(skillsDir, entry.Name())
parts = append(parts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: name,
SkillDescription: description,
SkillDir: skillDir,
ContextFileSkillMetaFile: metaFile,
})
}
}
return parts
}
-439
View File
@@ -1,439 +0,0 @@
package agentcontextconfig_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontextconfig"
"github.com/coder/coder/v2/codersdk"
)
// filterParts returns only the parts matching the given type.
func filterParts(parts []codersdk.ChatMessagePart, t codersdk.ChatMessagePartType) []codersdk.ChatMessagePart {
var out []codersdk.ChatMessagePart
for _, p := range parts {
if p.Type == t {
out = append(out, p)
}
}
return out
}
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, mcpFiles := agentcontextconfig.Config(workDir)
// Parts is always non-nil.
require.NotNil(t, cfg.Parts)
// Default MCP config file is ".mcp.json" (relative),
// resolved against the working directory.
require.Equal(t, []string{filepath.Join(workDir, ".mcp.json")}, mcpFiles)
})
t.Run("CustomEnvVars", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
optInstructions := t.TempDir()
optSkills := t.TempDir()
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)
// Create files matching the custom names so we can
// verify the env vars actually change lookup behavior.
require.NoError(t, os.WriteFile(filepath.Join(optInstructions, "CUSTOM.md"), []byte("custom instructions"), 0o600))
skillDir := filepath.Join(optSkills, "my-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(skillDir, "META.yaml"),
[]byte("---\nname: my-skill\ndescription: custom meta\n---\n"),
0o600,
))
workDir := platformAbsPath("work")
cfg, mcpFiles := agentcontextconfig.Config(workDir)
require.Equal(t, []string{optMCP}, mcpFiles)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 1)
require.Equal(t, "custom instructions", ctxFiles[0].ContextFileContent)
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
require.Len(t, skillParts, 1)
require.Equal(t, "my-skill", skillParts[0].SkillName)
require.Equal(t, "META.yaml", skillParts[0].ContextFileSkillMetaFile)
})
t.Run("WhitespaceInFileNames", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsFile, " CLAUDE.md ")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
// Create a file matching the trimmed name.
require.NoError(t, os.WriteFile(filepath.Join(fakeHome, "CLAUDE.md"), []byte("hello"), 0o600))
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 1)
require.Equal(t, "hello", ctxFiles[0].ContextFileContent)
})
t.Run("CommaSeparatedDirs", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
a := t.TempDir()
b := t.TempDir()
t.Setenv(agentcontextconfig.EnvInstructionsDirs, a+","+b)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
// Put instruction files in both dirs.
require.NoError(t, os.WriteFile(filepath.Join(a, "AGENTS.md"), []byte("from a"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(b, "AGENTS.md"), []byte("from b"), 0o600))
workDir := t.TempDir()
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 2)
require.Equal(t, "from a", ctxFiles[0].ContextFileContent)
require.Equal(t, "from b", ctxFiles[1].ContextFileContent)
})
t.Run("ReadsInstructionFiles", func(t *testing.T) {
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
// Create ~/.coder/AGENTS.md
coderDir := filepath.Join(fakeHome, ".coder")
require.NoError(t, os.MkdirAll(coderDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(coderDir, "AGENTS.md"),
[]byte("home instructions"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.NotNil(t, cfg.Parts)
require.Len(t, ctxFiles, 1)
require.Equal(t, "home instructions", ctxFiles[0].ContextFileContent)
require.Equal(t, filepath.Join(coderDir, "AGENTS.md"), ctxFiles[0].ContextFilePath)
require.False(t, ctxFiles[0].ContextFileTruncated)
})
t.Run("ReadsWorkingDirInstructionFile", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
// Create AGENTS.md in the working directory.
require.NoError(t, os.WriteFile(
filepath.Join(workDir, "AGENTS.md"),
[]byte("project instructions"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
// Should find the working dir file (not in instruction dirs).
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.NotNil(t, cfg.Parts)
require.Len(t, ctxFiles, 1)
require.Equal(t, "project instructions", ctxFiles[0].ContextFileContent)
require.Equal(t, filepath.Join(workDir, "AGENTS.md"), ctxFiles[0].ContextFilePath)
})
t.Run("TruncatesLargeInstructionFile", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
largeContent := strings.Repeat("a", 64*1024+100)
require.NoError(t, os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(largeContent), 0o600))
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 1)
require.True(t, ctxFiles[0].ContextFileTruncated)
require.Len(t, ctxFiles[0].ContextFileContent, 64*1024)
})
t.Run("SanitizesHTMLComments", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(workDir, "AGENTS.md"),
[]byte("visible\n<!-- hidden -->content"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 1)
require.Equal(t, "visible\ncontent", ctxFiles[0].ContextFileContent)
})
t.Run("SanitizesInvisibleUnicode", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
// U+200B (zero-width space) should be stripped.
require.NoError(t, os.WriteFile(
filepath.Join(workDir, "AGENTS.md"),
[]byte("before\u200bafter"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 1)
require.Equal(t, "beforeafter", ctxFiles[0].ContextFileContent)
})
t.Run("NormalizesCRLF", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(workDir, "AGENTS.md"),
[]byte("line1\r\nline2\rline3"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
require.Len(t, ctxFiles, 1)
require.Equal(t, "line1\nline2\nline3", ctxFiles[0].ContextFileContent)
})
t.Run("DiscoversSkills", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
skillsDir := filepath.Join(workDir, ".agents", "skills")
t.Setenv(agentcontextconfig.EnvSkillsDirs, skillsDir)
// Create a valid skill.
skillDir := filepath.Join(skillsDir, "my-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: my-skill\ndescription: A test skill\n---\nSkill body"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
require.Len(t, skillParts, 1)
require.Equal(t, "my-skill", skillParts[0].SkillName)
require.Equal(t, "A test skill", skillParts[0].SkillDescription)
require.Equal(t, skillDir, skillParts[0].SkillDir)
require.Equal(t, "SKILL.md", skillParts[0].ContextFileSkillMetaFile)
})
t.Run("SkipsMissingDirs", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
nonExistent := filepath.Join(t.TempDir(), "does-not-exist")
t.Setenv(agentcontextconfig.EnvInstructionsDirs, nonExistent)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, nonExistent)
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
cfg, _ := agentcontextconfig.Config(workDir)
// Non-nil empty slice (signals agent supports new format).
require.NotNil(t, cfg.Parts)
require.Empty(t, cfg.Parts)
})
t.Run("MCPConfigFilesResolvedSeparately", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
optMCP := platformAbsPath("opt", "custom.json")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, optMCP)
workDir := t.TempDir()
_, mcpFiles := agentcontextconfig.Config(workDir)
require.Equal(t, []string{optMCP}, mcpFiles)
})
t.Run("SkillNameMustMatchDir", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
skillsDir := filepath.Join(workDir, "skills")
t.Setenv(agentcontextconfig.EnvSkillsDirs, skillsDir)
// Skill name in frontmatter doesn't match directory name.
skillDir := filepath.Join(skillsDir, "wrong-dir-name")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: actual-name\ndescription: mismatch\n---\n"),
0o600,
))
cfg, _ := agentcontextconfig.Config(workDir)
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
require.Empty(t, skillParts)
})
t.Run("DuplicateSkillsFirstWins", func(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("USERPROFILE", fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
workDir := t.TempDir()
skillsDir1 := filepath.Join(workDir, "skills1")
skillsDir2 := filepath.Join(workDir, "skills2")
t.Setenv(agentcontextconfig.EnvSkillsDirs, skillsDir1+","+skillsDir2)
// Same skill name in both directories.
for _, dir := range []string{skillsDir1, skillsDir2} {
skillDir := filepath.Join(dir, "dup-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: dup-skill\ndescription: from "+filepath.Base(dir)+"\n---\n"),
0o600,
))
}
cfg, _ := agentcontextconfig.Config(workDir)
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
require.Len(t, skillParts, 1)
require.Equal(t, "from skills1", skillParts[0].SkillDescription)
})
}
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, MCP paths resolve to nothing.
mcpFiles := api.MCPConfigFiles()
require.Empty(t, mcpFiles)
// After setting the directory, MCPConfigFiles() picks it up.
dir = platformAbsPath("work")
mcpFiles = api.MCPConfigFiles()
require.NotEmpty(t, mcpFiles)
require.Equal(t, []string{filepath.Join(dir, ".mcp.json")}, mcpFiles)
}
-55
View File
@@ -1,55 +0,0 @@
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
@@ -1,152 +0,0 @@
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,11 +148,6 @@ 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
View File
@@ -32,7 +32,6 @@ func (a *agent) apiHandler() http.Handler {
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())
-156
View File
@@ -1,17 +1,12 @@
package agentdesktop
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentssh"
@@ -52,9 +47,6 @@ type API struct {
logger slog.Logger
desktop Desktop
clock quartz.Clock
closeMu sync.Mutex
closed bool
}
// NewAPI creates a new desktop streaming API.
@@ -74,10 +66,6 @@ func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/vnc", a.handleDesktopVNC)
r.Post("/action", a.handleAction)
r.Route("/recording", func(r chi.Router) {
r.Post("/start", a.handleRecordingStart)
r.Post("/stop", a.handleRecordingStop)
})
return r
}
@@ -128,9 +116,6 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
handlerStart := a.clock.Now()
// Update last desktop action timestamp for idle recording monitor.
a.desktop.RecordActivity()
// Ensure the desktop is running and grab native dimensions.
cfg, err := a.desktop.Start(ctx)
if err != nil {
@@ -495,150 +480,9 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
// Close shuts down the desktop session if one is running.
func (a *API) Close() error {
a.closeMu.Lock()
if a.closed {
a.closeMu.Unlock()
return nil
}
a.closed = true
a.closeMu.Unlock()
return a.desktop.Close()
}
// decodeRecordingRequest decodes and validates a recording request
// from the HTTP body, returning the recording ID. Returns false if
// the request was invalid and an error response was already written.
func (*API) decodeRecordingRequest(rw http.ResponseWriter, r *http.Request) (string, bool) {
ctx := r.Context()
var req struct {
RecordingID string `json:"recording_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to decode request body.",
Detail: err.Error(),
})
return "", false
}
if req.RecordingID == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing recording_id.",
})
return "", false
}
if _, err := uuid.Parse(req.RecordingID); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid recording_id format.",
Detail: "recording_id must be a valid UUID.",
})
return "", false
}
return req.RecordingID, true
}
func (a *API) handleRecordingStart(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
recordingID, ok := a.decodeRecordingRequest(rw, r)
if !ok {
return
}
a.closeMu.Lock()
if a.closed {
a.closeMu.Unlock()
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
Message: "Desktop API is shutting down.",
})
return
}
a.closeMu.Unlock()
if err := a.desktop.StartRecording(ctx, recordingID); err != nil {
if errors.Is(err, ErrDesktopClosed) {
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
Message: "Desktop API is shutting down.",
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to start recording.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "Recording started.",
})
}
func (a *API) handleRecordingStop(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
recordingID, ok := a.decodeRecordingRequest(rw, r)
if !ok {
return
}
a.closeMu.Lock()
if a.closed {
a.closeMu.Unlock()
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
Message: "Desktop API is shutting down.",
})
return
}
a.closeMu.Unlock()
// Stop recording (idempotent).
// Use a context detached from the HTTP request so that if the
// connection drops, the recording process can still shut down
// gracefully. WithoutCancel preserves request-scoped values.
stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
defer stopCancel()
artifact, err := a.desktop.StopRecording(stopCtx, recordingID)
if err != nil {
if errors.Is(err, ErrUnknownRecording) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Recording not found.",
Detail: err.Error(),
})
return
}
if errors.Is(err, ErrRecordingCorrupted) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Recording is corrupted.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to stop recording.",
Detail: err.Error(),
})
return
}
defer artifact.Reader.Close()
if artifact.Size > workspacesdk.MaxRecordingSize {
a.logger.Warn(ctx, "recording file exceeds maximum size",
slog.F("recording_id", recordingID),
slog.F("size", artifact.Size),
slog.F("max_size", workspacesdk.MaxRecordingSize),
)
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "Recording file exceeds maximum allowed size.",
})
return
}
rw.Header().Set("Content-Type", "video/mp4")
rw.Header().Set("Content-Length", strconv.FormatInt(artifact.Size, 10))
rw.WriteHeader(http.StatusOK)
_, _ = io.Copy(rw, artifact.Reader)
}
// coordFromAction extracts the coordinate pair from a DesktopAction,
// returning an error if the coordinate field is missing.
func coordFromAction(action DesktopAction) (x, y int, err error) {
-661
View File
@@ -4,17 +4,12 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"slices"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
@@ -26,16 +21,6 @@ import (
"github.com/coder/quartz"
)
// Test recording UUIDs used across tests.
const (
testRecIDDefault = "870e1f02-8118-4300-a37e-4adb0117baf3"
testRecIDStartIdempotent = "250a2ffb-a5e5-4c94-9754-4d6a4ab7ba20"
testRecIDStopIdempotent = "38f8a378-f98f-4758-a4ae-950b44cf989a"
testRecIDConcurrentA = "8dc173eb-23c6-4601-a485-b6dfb2a42c3a"
testRecIDConcurrentB = "fea490d4-70f0-4798-a181-29d65ce25ae1"
testRecIDRestart = "75173a0d-b018-4e2e-a771-defa3fc6af69"
)
// Ensure fakeDesktop satisfies the Desktop interface at compile time.
var _ agentdesktop.Desktop = (*fakeDesktop)(nil)
@@ -58,14 +43,6 @@ type fakeDesktop struct {
lastTyped string
lastKeyDown string
lastKeyUp string
// Recording tracking (guarded by recMu).
recMu sync.Mutex
recordings map[string]string // ID → file path
stopCalls []string // recording IDs passed to StopRecording
recStopCh chan string // optional: signaled when StopRecording is called
startCount int // incremented on each new recording start
activityCount int // incremented by RecordActivity
}
func (f *fakeDesktop) Start(context.Context) (agentdesktop.DisplayConfig, error) {
@@ -130,140 +107,11 @@ func (f *fakeDesktop) CursorPosition(context.Context) (x int, y int, err error)
return f.cursorPos[0], f.cursorPos[1], nil
}
func (f *fakeDesktop) StartRecording(_ context.Context, recordingID string) error {
f.recMu.Lock()
defer f.recMu.Unlock()
if f.recordings == nil {
f.recordings = make(map[string]string)
}
if path, ok := f.recordings[recordingID]; ok {
// Check if already stopped (file still exists but stop was
// called). For the fake, a stopped recording means its ID
// appears in stopCalls. In that case, remove the old file
// and start fresh.
stopped := slices.Contains(f.stopCalls, recordingID)
if !stopped {
// Active recording - no-op.
return nil
}
// Completed recording - discard old file, start fresh.
_ = os.Remove(path)
delete(f.recordings, recordingID)
}
f.startCount++
tmpFile, err := os.CreateTemp("", "fake-recording-*.mp4")
if err != nil {
return err
}
_, _ = tmpFile.Write([]byte(fmt.Sprintf("fake-mp4-data-%s-%d", recordingID, f.startCount)))
_ = tmpFile.Close()
f.recordings[recordingID] = tmpFile.Name()
return nil
}
func (f *fakeDesktop) StopRecording(_ context.Context, recordingID string) (*agentdesktop.RecordingArtifact, error) {
f.recMu.Lock()
defer f.recMu.Unlock()
if f.recordings == nil {
return nil, agentdesktop.ErrUnknownRecording
}
path, ok := f.recordings[recordingID]
if !ok {
return nil, agentdesktop.ErrUnknownRecording
}
f.stopCalls = append(f.stopCalls, recordingID)
if f.recStopCh != nil {
select {
case f.recStopCh <- recordingID:
default:
}
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
return &agentdesktop.RecordingArtifact{
Reader: file,
Size: info.Size(),
}, nil
}
func (f *fakeDesktop) RecordActivity() {
f.recMu.Lock()
f.activityCount++
f.recMu.Unlock()
}
func (f *fakeDesktop) Close() error {
f.closed = true
f.recMu.Lock()
defer f.recMu.Unlock()
for _, path := range f.recordings {
_ = os.Remove(path)
}
return nil
}
// failStartRecordingDesktop wraps fakeDesktop and overrides
// StartRecording to always return an error.
type failStartRecordingDesktop struct {
fakeDesktop
startRecordingErr error
}
func (f *failStartRecordingDesktop) StartRecording(_ context.Context, _ string) error {
return f.startRecordingErr
}
// corruptedStopDesktop wraps fakeDesktop and overrides
// StopRecording to always return ErrRecordingCorrupted.
type corruptedStopDesktop struct {
fakeDesktop
}
func (*corruptedStopDesktop) StopRecording(_ context.Context, _ string) (*agentdesktop.RecordingArtifact, error) {
return nil, agentdesktop.ErrRecordingCorrupted
}
// oversizedFakeDesktop wraps fakeDesktop and expands recording files
// beyond MaxRecordingSize when StopRecording is called.
type oversizedFakeDesktop struct {
fakeDesktop
}
func (f *oversizedFakeDesktop) StopRecording(ctx context.Context, recordingID string) (*agentdesktop.RecordingArtifact, error) {
artifact, err := f.fakeDesktop.StopRecording(ctx, recordingID)
if err != nil {
return nil, err
}
// Close the original reader since we're going to re-open after truncation.
artifact.Reader.Close()
// Look up the path from the fakeDesktop recordings.
f.fakeDesktop.recMu.Lock()
path := f.fakeDesktop.recordings[recordingID]
f.fakeDesktop.recMu.Unlock()
// Expand the file to exceed the maximum recording size.
if err := os.Truncate(path, workspacesdk.MaxRecordingSize+1); err != nil {
return nil, err
}
// Re-open the truncated file.
file, err := os.Open(path)
if err != nil {
return nil, err
}
return &agentdesktop.RecordingArtifact{
Reader: file,
Size: workspacesdk.MaxRecordingSize + 1,
}, nil
}
func TestHandleDesktopVNC_StartError(t *testing.T) {
t.Parallel()
@@ -286,37 +134,6 @@ func TestHandleDesktopVNC_StartError(t *testing.T) {
assert.Equal(t, "Failed to start desktop session.", resp.Message)
}
func TestHandleAction_CallsRecordActivity(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
body := agentdesktop.DesktopAction{
Action: "left_click",
Coordinate: &[2]int{100, 200},
}
b, err := json.Marshal(body)
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
handler := api.Routes()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
fake.recMu.Lock()
count := fake.activityCount
fake.recMu.Unlock()
assert.Equal(t, 1, count, "handleAction should call RecordActivity exactly once")
}
func TestHandleAction_Screenshot(t *testing.T) {
t.Parallel()
@@ -757,481 +574,3 @@ func TestHandleAction_CursorPositionReturnsDeclaredCoordinates(t *testing.T) {
// Native (960,540) in 1920x1080 should map to declared space in 1280x720.
assert.Equal(t, "x=640,y=360", resp.Output)
}
func TestRecordingStartStop(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Start recording.
startBody, err := json.Marshal(map[string]string{"recording_id": testRecIDDefault})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(startBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Stop recording.
stopBody, err := json.Marshal(map[string]string{"recording_id": testRecIDDefault})
require.NoError(t, err)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(stopBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "video/mp4", rr.Header().Get("Content-Type"))
assert.Equal(t, []byte("fake-mp4-data-"+testRecIDDefault+"-1"), rr.Body.Bytes())
}
func TestRecordingStartFails(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &failStartRecordingDesktop{
fakeDesktop: fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
},
startRecordingErr: xerrors.New("start recording error"),
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
body, err := json.Marshal(map[string]string{"recording_id": uuid.New().String()})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
var resp codersdk.Response
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, "Failed to start recording.", resp.Message)
}
func TestRecordingStartIdempotent(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Start same recording twice - both should succeed.
for range 2 {
body, err := json.Marshal(map[string]string{"recording_id": testRecIDStartIdempotent})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
// Stop once, verify normal response.
stopBody, err := json.Marshal(map[string]string{"recording_id": testRecIDStartIdempotent})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(stopBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "video/mp4", rr.Header().Get("Content-Type"))
assert.Equal(t, []byte("fake-mp4-data-"+testRecIDStartIdempotent+"-1"), rr.Body.Bytes())
}
func TestRecordingStopIdempotent(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Start recording.
startBody, err := json.Marshal(map[string]string{"recording_id": testRecIDStopIdempotent})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(startBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Stop twice - both should succeed with identical data.
var bodies [2][]byte
for i := range 2 {
body, err := json.Marshal(map[string]string{"recording_id": testRecIDStopIdempotent})
require.NoError(t, err)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(body))
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "video/mp4", recorder.Header().Get("Content-Type"))
bodies[i] = recorder.Body.Bytes()
}
assert.Equal(t, bodies[0], bodies[1])
}
func TestRecordingStopInvalidIDFormat(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
body, err := json.Marshal(map[string]string{"recording_id": "not-a-uuid"})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestRecordingStopUnknownRecording(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Send a valid UUID that was never started - should reach
// StopRecording, get ErrUnknownRecording, and return 404.
body, err := json.Marshal(map[string]string{"recording_id": uuid.New().String()})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
var resp codersdk.Response
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, "Recording not found.", resp.Message)
}
func TestRecordingStopOversizedFile(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &oversizedFakeDesktop{
fakeDesktop: fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Start recording.
recID := uuid.New().String()
startBody, err := json.Marshal(map[string]string{"recording_id": recID})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(startBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Stop recording - file exceeds max size, expect 413.
stopBody, err := json.Marshal(map[string]string{"recording_id": recID})
require.NoError(t, err)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(stopBody))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
var resp codersdk.Response
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, "Recording file exceeds maximum allowed size.", resp.Message)
}
func TestRecordingMultipleSimultaneous(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Start two recordings with different IDs.
for _, id := range []string{testRecIDConcurrentA, testRecIDConcurrentB} {
body, err := json.Marshal(map[string]string{"recording_id": id})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
// Stop both and verify each returns its own data.
expected := map[string][]byte{
testRecIDConcurrentA: []byte("fake-mp4-data-" + testRecIDConcurrentA + "-1"),
testRecIDConcurrentB: []byte("fake-mp4-data-" + testRecIDConcurrentB + "-2"),
}
for _, id := range []string{testRecIDConcurrentA, testRecIDConcurrentB} {
body, err := json.Marshal(map[string]string{"recording_id": id})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "video/mp4", rr.Header().Get("Content-Type"))
assert.Equal(t, expected[id], rr.Body.Bytes())
}
}
func TestRecordingStartMalformedBody(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader([]byte("not json")))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestRecordingStartEmptyID(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
body, err := json.Marshal(map[string]string{"recording_id": ""})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestRecordingStopEmptyID(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
body, err := json.Marshal(map[string]string{"recording_id": ""})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestRecordingStopMalformedBody(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader([]byte("not json")))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestRecordingStartAfterCompleted(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Step 1: Start recording.
startBody, err := json.Marshal(map[string]string{"recording_id": testRecIDRestart})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(startBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Step 2: Stop recording (gets first MP4 data).
stopBody, err := json.Marshal(map[string]string{"recording_id": testRecIDRestart})
require.NoError(t, err)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(stopBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "video/mp4", rr.Header().Get("Content-Type"))
firstData := rr.Body.Bytes()
require.NotEmpty(t, firstData)
// Step 3: Start again with the same ID - should succeed
// (old file discarded, new recording started).
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(startBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Step 4: Stop again - should return NEW MP4 data.
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(stopBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "video/mp4", rr.Header().Get("Content-Type"))
secondData := rr.Body.Bytes()
require.NotEmpty(t, secondData)
// The two recordings should have different data because the
// fake increments a counter on each fresh start.
assert.NotEqual(t, firstData, secondData,
"restarted recording should produce different data")
}
func TestRecordingStartAfterClose(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
handler := api.Routes()
// Close the API before sending the request.
api.Close()
body, err := json.Marshal(map[string]string{"recording_id": uuid.New().String()})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
var resp codersdk.Response
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, "Desktop API is shutting down.", resp.Message)
}
func TestRecordingStartDesktopClosed(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
// StartRecording returns ErrDesktopClosed to simulate a race
// where the desktop is closed between the API-level check and
// the desktop-level StartRecording call.
fake := &failStartRecordingDesktop{
fakeDesktop: fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
},
startRecordingErr: agentdesktop.ErrDesktopClosed,
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
body, err := json.Marshal(map[string]string{"recording_id": uuid.New().String()})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(body))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
var resp codersdk.Response
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.Equal(t, "Desktop API is shutting down.", resp.Message)
}
func TestRecordingStopCorrupted(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &corruptedStopDesktop{
fakeDesktop: fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
handler := api.Routes()
// Start a recording so the stop has something to find.
recID := uuid.New().String()
startBody, err := json.Marshal(map[string]string{"recording_id": recID})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/recording/start", bytes.NewReader(startBody))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Stop returns ErrRecordingCorrupted.
stopBody, err := json.Marshal(map[string]string{"recording_id": recID})
require.NoError(t, err)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/recording/stop", bytes.NewReader(stopBody))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
var respStop codersdk.Response
err = json.NewDecoder(rr.Body).Decode(&respStop)
require.NoError(t, err)
assert.Equal(t, "Recording is corrupted.", respStop.Message)
}
-45
View File
@@ -2,10 +2,7 @@ package agentdesktop
import (
"context"
"io"
"net"
"golang.org/x/xerrors"
)
// Desktop abstracts a virtual desktop session running inside a workspace.
@@ -61,52 +58,10 @@ type Desktop interface {
// CursorPosition returns the current cursor coordinates.
CursorPosition(ctx context.Context) (x, y int, err error)
// RecordActivity marks the desktop as having received user
// interaction, resetting the idle-recording timer.
RecordActivity()
// StartRecording begins recording the desktop to an MP4 file
// using the caller-provided recording ID. Safe to call
// repeatedly - active recordings continue unchanged, stopped
// recordings are discarded and restarted. Concurrent recordings
// are supported.
StartRecording(ctx context.Context, recordingID string) error
// StopRecording finalizes the recording identified by the given
// ID. Idempotent - safe to call on an already-stopped recording.
// Returns a RecordingArtifact that the caller can stream. The
// caller must close the artifact when done. Returns an error if
// the recording ID is unknown.
StopRecording(ctx context.Context, recordingID string) (*RecordingArtifact, error)
// Close shuts down the desktop session and cleans up resources.
Close() error
}
// ErrUnknownRecording is returned by StopRecording when the
// recording ID is not recognized.
var ErrUnknownRecording = xerrors.New("unknown recording ID")
// ErrDesktopClosed is returned when an operation is attempted on a
// closed desktop session.
var ErrDesktopClosed = xerrors.New("desktop closed")
// ErrRecordingCorrupted is returned by StopRecording when the
// recording process was force-killed and the artifact is likely
// incomplete or corrupt.
var ErrRecordingCorrupted = xerrors.New("recording corrupted: process was force-killed")
// RecordingArtifact is a finalized recording returned by StopRecording.
// The caller streams the artifact and must call Close when done. The
// artifact remains valid even if the same recording ID is restarted
// or the desktop is closed while the caller is reading.
type RecordingArtifact struct {
// Reader is the MP4 content. Callers must close it when done.
Reader io.ReadCloser
// Size is the byte length of the MP4 content.
Size int64
}
// DisplayConfig describes a running desktop session.
type DisplayConfig struct {
Width int // native width in pixels
+16 -385
View File
@@ -3,7 +3,6 @@ package agentdesktop
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
@@ -12,7 +11,6 @@ import (
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"golang.org/x/xerrors"
@@ -20,7 +18,6 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/quartz"
)
// portableDesktopOutput is the JSON output from
@@ -52,65 +49,32 @@ type screenshotOutput struct {
Data string `json:"data"`
}
// recordingProcess tracks a single desktop recording subprocess.
type recordingProcess struct {
cmd *exec.Cmd
filePath string
stopped bool
killed bool // true when the process was SIGKILLed
done chan struct{} // closed when cmd.Wait() returns
waitErr error // set before done is closed
stopOnce sync.Once
idleCancel context.CancelFunc // cancels the per-recording idle goroutine
idleDone chan struct{} // closed when idle goroutine exits
}
// maxConcurrentRecordings is the maximum number of active (non-stopped)
// recordings allowed at once. This prevents resource exhaustion.
const maxConcurrentRecordings = 5
// idleTimeout is the duration of desktop inactivity after which all
// active recordings are automatically stopped.
const idleTimeout = 10 * time.Minute
// portableDesktop implements Desktop by shelling out to the
// portabledesktop CLI via agentexec.Execer.
type portableDesktop struct {
logger slog.Logger
execer agentexec.Execer
scriptBinDir string // coder script bin directory
clock quartz.Clock
mu sync.Mutex
session *desktopSession // nil until started
binPath string // resolved path to binary, cached
closed bool
recordings map[string]*recordingProcess // guarded by mu
lastDesktopActionAt atomic.Int64
mu sync.Mutex
session *desktopSession // nil until started
binPath string // resolved path to binary, cached
closed bool
}
// NewPortableDesktop creates a Desktop backed by the portabledesktop
// CLI binary, using execer to spawn child processes. scriptBinDir is
// the coder script bin directory checked for the binary. If clk is
// nil, a real clock is used.
// the coder script bin directory checked for the binary.
func NewPortableDesktop(
logger slog.Logger,
execer agentexec.Execer,
scriptBinDir string,
clk quartz.Clock,
) Desktop {
if clk == nil {
clk = quartz.NewReal()
}
pd := &portableDesktop{
return &portableDesktop{
logger: logger,
execer: execer,
scriptBinDir: scriptBinDir,
clock: clk,
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
return pd
}
// Start launches the desktop session (idempotent).
@@ -119,7 +83,7 @@ func (p *portableDesktop) Start(ctx context.Context) (DisplayConfig, error) {
defer p.mu.Unlock()
if p.closed {
return DisplayConfig{}, ErrDesktopClosed
return DisplayConfig{}, xerrors.New("desktop is closed")
}
if err := p.ensureBinary(ctx); err != nil {
@@ -349,328 +313,23 @@ func (p *portableDesktop) CursorPosition(ctx context.Context) (x int, y int, err
return result.X, result.Y, nil
}
// StartRecording begins recording the desktop to an MP4 file.
// Three-state idempotency: active recordings are no-ops,
// completed recordings are discarded and restarted.
func (p *portableDesktop) StartRecording(ctx context.Context, recordingID string) error {
// Ensure the desktop session is running before acquiring the
// recording lock. Start is independently locked and idempotent.
if _, err := p.Start(ctx); err != nil {
return xerrors.Errorf("ensure desktop session: %w", err)
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return ErrDesktopClosed
}
// Three-state idempotency:
// - Active recording → no-op, continue recording.
// - Completed recording → discard old file, start fresh.
// - Unknown ID → fall through to start a new recording.
if rec, ok := p.recordings[recordingID]; ok {
if !rec.stopped {
select {
case <-rec.done:
// Process exited unexpectedly; treat as completed
// so we fall through to discard the old file and
// restart.
default:
// Active recording - no-op, continue recording.
return nil
}
}
// Completed recording - discard old file, start fresh.
if err := os.Remove(rec.filePath); err != nil && !os.IsNotExist(err) {
p.logger.Warn(ctx, "failed to remove old recording file",
slog.F("recording_id", recordingID),
slog.F("file_path", rec.filePath),
slog.Error(err),
)
}
delete(p.recordings, recordingID)
}
// Check concurrent recording limit.
if p.lockedActiveRecordingCount() >= maxConcurrentRecordings {
return xerrors.Errorf("too many concurrent recordings (max %d)", maxConcurrentRecordings)
}
// GC sweep: remove stopped recordings with stale files.
p.lockedCleanStaleRecordings(ctx)
if err := p.ensureBinary(ctx); err != nil {
return xerrors.Errorf("ensure portabledesktop binary: %w", err)
}
filePath := filepath.Join(os.TempDir(), "coder-recording-"+recordingID+".mp4")
// Use a background context so the process outlives the HTTP
// request that triggered it.
procCtx, procCancel := context.WithCancel(context.Background())
//nolint:gosec // portabledesktop is a trusted binary resolved via ensureBinary.
cmd := p.execer.CommandContext(procCtx, p.binPath, "record",
// The following options are used to speed up the recording when the desktop is idle.
// They were taken out of an example in the portabledesktop repo.
// There's likely room for improvement to optimize the values.
"--idle-speedup", "20",
"--idle-min-duration", "0.35",
"--idle-noise-tolerance", "-38dB",
filePath)
if err := cmd.Start(); err != nil {
procCancel()
return xerrors.Errorf("start recording process: %w", err)
}
rec := &recordingProcess{
cmd: cmd,
filePath: filePath,
done: make(chan struct{}),
}
go func() {
rec.waitErr = cmd.Wait()
close(rec.done)
// avoid a context resource leak by canceling the context
procCancel()
}()
p.recordings[recordingID] = rec
p.logger.Info(ctx, "started desktop recording",
slog.F("recording_id", recordingID),
slog.F("file_path", filePath),
slog.F("pid", cmd.Process.Pid),
)
// Record activity so a recording started on an already-idle
// desktop does not stop immediately.
p.lastDesktopActionAt.Store(p.clock.Now().UnixNano())
// Spawn a per-recording idle goroutine.
idleCtx, idleCancel := context.WithCancel(context.Background())
rec.idleCancel = idleCancel
rec.idleDone = make(chan struct{})
go func() {
defer close(rec.idleDone)
p.monitorRecordingIdle(idleCtx, rec)
}()
return nil
}
// StopRecording finalizes the recording. Idempotent - safe to call
// on an already-stopped recording. Returns a RecordingArtifact
// that the caller can stream. The caller must close the Reader
// on the returned artifact to avoid leaking file descriptors.
func (p *portableDesktop) StopRecording(ctx context.Context, recordingID string) (*RecordingArtifact, error) {
p.mu.Lock()
rec, ok := p.recordings[recordingID]
if !ok {
p.mu.Unlock()
return nil, ErrUnknownRecording
}
p.lockedStopRecordingProcess(ctx, rec, false)
killed := rec.killed
p.mu.Unlock()
p.logger.Info(ctx, "stopped desktop recording",
slog.F("recording_id", recordingID),
slog.F("file_path", rec.filePath),
)
if killed {
return nil, ErrRecordingCorrupted
}
// Open the file and return an artifact. Each call opens a fresh
// file descriptor so the caller is insulated from restarts and
// desktop close.
f, err := os.Open(rec.filePath)
if err != nil {
return nil, xerrors.Errorf("open recording artifact: %w", err)
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, xerrors.Errorf("stat recording artifact: %w", err)
}
return &RecordingArtifact{
Reader: f,
Size: info.Size(),
}, nil
}
// lockedStopRecordingProcess stops a single recording via stopOnce.
// It sends SIGINT, waits up to 15 seconds for graceful exit, then
// SIGKILLs. When force is true the process is SIGKILLed immediately
// without attempting a graceful shutdown. Must be called while p.mu
// is held; the lock is held for the full duration so that no
// concurrent StopRecording caller can read rec.stopped = true
// before the process has finished writing the MP4 file.
//
//nolint:revive // force flag keeps shared stopOnce/cleanup logic in one place.
func (p *portableDesktop) lockedStopRecordingProcess(ctx context.Context, rec *recordingProcess, force bool) {
rec.stopOnce.Do(func() {
if force {
_ = rec.cmd.Process.Kill()
rec.killed = true
} else {
_ = interruptRecordingProcess(rec.cmd.Process)
timer := p.clock.NewTimer(15*time.Second, "agentdesktop", "stop_timeout")
defer timer.Stop()
select {
case <-rec.done:
case <-ctx.Done():
_ = rec.cmd.Process.Kill()
rec.killed = true
case <-timer.C:
_ = rec.cmd.Process.Kill()
rec.killed = true
}
}
rec.stopped = true
if rec.idleCancel != nil {
rec.idleCancel()
}
})
// NOTE: We intentionally do not wait on rec.done here.
// If goleak is added to this package's tests, this may
// need revisiting to avoid flakes.
}
// lockedActiveRecordingCount returns the number of recordings that
// are still actively running. Must be called while p.mu is held.
// The max concurrency is low (maxConcurrentRecordings = 5), so a
// full scan is cheap and avoids maintaining a separate counter.
func (p *portableDesktop) lockedActiveRecordingCount() int {
active := 0
for _, rec := range p.recordings {
if rec.stopped {
continue
}
select {
case <-rec.done:
default:
active++
}
}
return active
}
// lockedCleanStaleRecordings removes stopped recordings whose temp
// files are older than one hour. Must be called while p.mu is held.
func (p *portableDesktop) lockedCleanStaleRecordings(ctx context.Context) {
for id, rec := range p.recordings {
if !rec.stopped {
continue
}
info, err := os.Stat(rec.filePath)
if err != nil {
// File already removed or inaccessible; drop entry.
delete(p.recordings, id)
continue
}
if p.clock.Since(info.ModTime()) > time.Hour {
if err := os.Remove(rec.filePath); err != nil && !os.IsNotExist(err) {
p.logger.Warn(ctx, "failed to remove stale recording file",
slog.F("recording_id", id),
slog.F("file_path", rec.filePath),
slog.Error(err),
)
}
delete(p.recordings, id)
}
}
}
// Close shuts down the desktop session and cleans up resources.
func (p *portableDesktop) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
// Force-kill all active recordings. The stopOnce inside
// lockedStopRecordingProcess makes this safe for
// already-stopped recordings.
for _, rec := range p.recordings {
p.lockedStopRecordingProcess(context.Background(), rec, true)
}
// Snapshot recording file paths and idle goroutine channels
// for cleanup, then clear the map.
type recEntry struct {
id string
filePath string
idleDone chan struct{}
}
var allRecs []recEntry
for id, rec := range p.recordings {
allRecs = append(allRecs, recEntry{id: id, filePath: rec.filePath, idleDone: rec.idleDone})
delete(p.recordings, id)
}
session := p.session
p.session = nil
p.mu.Unlock()
// Wait for all per-recording idle goroutines to exit.
for _, entry := range allRecs {
if entry.idleDone != nil {
<-entry.idleDone
}
}
// Remove all recording files and wait for the session to
// exit with a timeout so a slow filesystem or hung process
// cannot block agent shutdown indefinitely.
cleanupDone := make(chan struct{})
go func() {
defer close(cleanupDone)
for _, entry := range allRecs {
if err := os.Remove(entry.filePath); err != nil && !os.IsNotExist(err) {
p.logger.Warn(context.Background(), "failed to remove recording file on close",
slog.F("recording_id", entry.id),
slog.F("file_path", entry.filePath),
slog.Error(err),
)
}
}
if session != nil {
session.cancel()
if err := session.cmd.Process.Kill(); err != nil {
p.logger.Warn(context.Background(), "failed to kill portabledesktop process",
slog.Error(err),
)
}
if err := session.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
p.logger.Warn(context.Background(), "portabledesktop process exited with error",
slog.Error(err),
)
}
}
}
}()
timer := p.clock.NewTimer(15*time.Second, "agentdesktop", "close_cleanup_timeout")
defer timer.Stop()
select {
case <-cleanupDone:
case <-timer.C:
p.logger.Warn(context.Background(), "timed out waiting for close cleanup")
if p.session != nil {
p.session.cancel()
// Xvnc is a child process — killing it cleans up the X
// session.
_ = p.session.cmd.Process.Kill()
_ = p.session.cmd.Wait()
p.session = nil
}
return nil
}
// RecordActivity marks the desktop as having received user
// interaction, resetting the idle-recording timer.
func (p *portableDesktop) RecordActivity() {
p.lastDesktopActionAt.Store(p.clock.Now().UnixNano())
}
// runCmd executes a portabledesktop subcommand and returns combined
// output. The caller must have previously called ensureBinary.
func (p *portableDesktop) runCmd(ctx context.Context, args ...string) (string, error) {
@@ -738,31 +397,3 @@ func (p *portableDesktop) ensureBinary(ctx context.Context) error {
return xerrors.New("portabledesktop binary not found in PATH or script bin directory")
}
// monitorRecordingIdle watches for desktop inactivity and stops the
// given recording when the idle timeout is reached.
func (p *portableDesktop) monitorRecordingIdle(ctx context.Context, rec *recordingProcess) {
timer := p.clock.NewTimer(idleTimeout, "agentdesktop", "recording_idle")
defer timer.Stop()
for {
select {
case <-timer.C:
lastNano := p.lastDesktopActionAt.Load()
lastAction := time.Unix(0, lastNano)
elapsed := p.clock.Since(lastAction)
if elapsed >= idleTimeout {
p.mu.Lock()
p.lockedStopRecordingProcess(context.Background(), rec, false)
p.mu.Unlock()
return
}
// Activity happened; reset with remaining budget.
timer.Reset(idleTimeout-elapsed, "agentdesktop", "recording_idle")
case <-rec.done:
return
case <-ctx.Done():
return
}
}
}
@@ -9,17 +9,13 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// recordedExecer implements agentexec.Execer by recording every
@@ -90,7 +86,6 @@ func TestPortableDesktop_Start_ParsesOutput(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop", // pre-set so ensureBinary is a no-op
clock: quartz.NewReal(),
}
ctx := t.Context()
@@ -122,7 +117,6 @@ func TestPortableDesktop_Start_Idempotent(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop",
clock: quartz.NewReal(),
}
ctx := t.Context()
@@ -165,7 +159,6 @@ func TestPortableDesktop_Screenshot(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop",
clock: quartz.NewReal(),
}
ctx := t.Context()
@@ -191,7 +184,6 @@ func TestPortableDesktop_Screenshot_WithTargetDimensions(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop",
clock: quartz.NewReal(),
}
ctx := t.Context()
@@ -290,7 +282,6 @@ func TestPortableDesktop_MouseMethods(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop",
clock: quartz.NewReal(),
}
err := tt.invoke(t.Context(), pd)
@@ -298,6 +289,7 @@ func TestPortableDesktop_MouseMethods(t *testing.T) {
cmds := rec.allCommands()
require.NotEmpty(t, cmds, "expected at least one command")
// Find at least one recorded command that contains
// all expected argument substrings.
found := false
@@ -375,7 +367,6 @@ func TestPortableDesktop_KeyboardMethods(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop",
clock: quartz.NewReal(),
}
err := tt.invoke(t.Context(), pd)
@@ -432,7 +423,6 @@ func TestPortableDesktop_Close(t *testing.T) {
execer: rec,
scriptBinDir: t.TempDir(),
binPath: "portabledesktop",
clock: quartz.NewReal(),
}
ctx := t.Context()
@@ -455,7 +445,7 @@ func TestPortableDesktop_Close(t *testing.T) {
// Subsequent Start must fail.
_, err = pd.Start(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "desktop closed")
assert.Contains(t, err.Error(), "desktop is closed")
}
// --- ensureBinary tests ---
@@ -549,410 +539,7 @@ func TestEnsureBinary_NotFound(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
func TestPortableDesktop_StartRecording(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewReal()
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
recID := uuid.New().String()
err := pd.StartRecording(ctx, recID)
require.NoError(t, err)
cmds := rec.allCommands()
require.NotEmpty(t, cmds)
// Find the record command (not the up command).
found := false
for _, cmd := range cmds {
joined := strings.Join(cmd, " ")
if strings.Contains(joined, "record") && strings.Contains(joined, "coder-recording-"+recID) {
found = true
break
}
}
assert.True(t, found, "expected a record command with the recording ID")
require.NoError(t, pd.Close())
}
func TestPortableDesktop_StartRecording_ConcurrentLimit(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewReal()
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
for i := range maxConcurrentRecordings {
err := pd.StartRecording(ctx, uuid.New().String())
require.NoError(t, err, "recording %d should succeed", i)
}
err := pd.StartRecording(ctx, uuid.New().String())
require.Error(t, err)
assert.Contains(t, err.Error(), "too many concurrent recordings")
require.NoError(t, pd.Close())
}
func TestPortableDesktop_StopRecording_ReturnsArtifact(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewReal()
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
recID := uuid.New().String()
err := pd.StartRecording(ctx, recID)
require.NoError(t, err)
// Write a dummy MP4 file at the expected path so StopRecording
// can open it as an artifact.
filePath := filepath.Join(os.TempDir(), "coder-recording-"+recID+".mp4")
require.NoError(t, os.WriteFile(filePath, []byte("fake-mp4-data"), 0o600))
t.Cleanup(func() { _ = os.Remove(filePath) })
artifact, err := pd.StopRecording(ctx, recID)
require.NoError(t, err)
defer artifact.Reader.Close()
assert.Equal(t, int64(len("fake-mp4-data")), artifact.Size)
require.NoError(t, pd.Close())
}
func TestPortableDesktop_StopRecording_UnknownID(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
},
}
clk := quartz.NewReal()
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
_, err := pd.StopRecording(ctx, uuid.New().String())
require.ErrorIs(t, err, ErrUnknownRecording)
require.NoError(t, pd.Close())
}
// Ensure that portableDesktop satisfies the Desktop interface at
// compile time. This uses the unexported type so it lives in the
// internal test package.
var _ Desktop = (*portableDesktop)(nil)
func TestPortableDesktop_IdleTimeout_StopsRecordings(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewMock(t)
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
recID := uuid.New().String()
// Install the trap before StartRecording so it is guaranteed
// to catch the idle monitor's NewTimer call regardless of
// goroutine scheduling.
trap := clk.Trap().NewTimer("agentdesktop", "recording_idle")
err := pd.StartRecording(ctx, recID)
require.NoError(t, err)
// Verify recording is active.
pd.mu.Lock()
require.False(t, pd.recordings[recID].stopped)
pd.mu.Unlock()
// Wait for the idle monitor timer to be created and release
// it so the monitor enters its select loop.
trap.MustWait(ctx).MustRelease(ctx)
trap.Close()
// The stop-all path calls lockedStopRecordingProcess which
// creates a per-recording 15s stop_timeout timer.
stopTrap := clk.Trap().NewTimer("agentdesktop", "stop_timeout")
// Advance past idle timeout to trigger the stop-all.
clk.Advance(idleTimeout)
// Wait for the stop timer to be created, then release it.
stopTrap.MustWait(ctx).MustRelease(ctx)
stopTrap.Close()
// The recording process should now be stopped.
require.Eventually(t, func() bool {
pd.mu.Lock()
defer pd.mu.Unlock()
rec, ok := pd.recordings[recID]
return ok && rec.stopped
}, testutil.WaitShort, testutil.IntervalFast)
require.NoError(t, pd.Close())
}
func TestPortableDesktop_IdleTimeout_ActivityResetsTimer(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewMock(t)
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
recID := uuid.New().String()
// Install the trap before StartRecording so it is guaranteed
// to catch the idle monitor's NewTimer call regardless of
// goroutine scheduling.
trap := clk.Trap().NewTimer("agentdesktop", "recording_idle")
err := pd.StartRecording(ctx, recID)
require.NoError(t, err)
// Wait for the idle monitor timer to be created.
trap.MustWait(ctx).MustRelease(ctx)
trap.Close()
// Advance most of the way but not past the timeout.
clk.Advance(idleTimeout - time.Minute)
// Record activity to reset the timer.
pd.RecordActivity()
// Trap the Reset call that the idle monitor makes when it
// sees recent activity.
resetTrap := clk.Trap().TimerReset("agentdesktop", "recording_idle")
// Advance past the original idle timeout deadline. The
// monitor should see the recent activity and reset instead
// of stopping.
clk.Advance(time.Minute)
resetTrap.MustWait(ctx).MustRelease(ctx)
resetTrap.Close()
// Recording should still be active because activity was
// recorded.
pd.mu.Lock()
require.False(t, pd.recordings[recID].stopped)
pd.mu.Unlock()
require.NoError(t, pd.Close())
}
func TestPortableDesktop_IdleTimeout_MultipleRecordings(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"record": `trap 'exit 0' INT; sleep 120 & wait`,
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewMock(t)
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
ctx := t.Context()
recID1 := uuid.New().String()
recID2 := uuid.New().String()
// Trap idle timer creation for both recordings.
trap := clk.Trap().NewTimer("agentdesktop", "recording_idle")
err := pd.StartRecording(ctx, recID1)
require.NoError(t, err)
// Wait for first recording's idle timer.
trap.MustWait(ctx).MustRelease(ctx)
err = pd.StartRecording(ctx, recID2)
require.NoError(t, err)
// Wait for second recording's idle timer.
trap.MustWait(ctx).MustRelease(ctx)
trap.Close()
// Trap the stop timers that will be created when idle fires.
stopTrap := clk.Trap().NewTimer("agentdesktop", "stop_timeout")
// Advance past idle timeout.
clk.Advance(idleTimeout)
// Wait for both stop timers.
stopTrap.MustWait(ctx).MustRelease(ctx)
stopTrap.MustWait(ctx).MustRelease(ctx)
stopTrap.Close()
// Both recordings should be stopped.
require.Eventually(t, func() bool {
pd.mu.Lock()
defer pd.mu.Unlock()
r1, ok1 := pd.recordings[recID1]
r2, ok2 := pd.recordings[recID2]
return ok1 && r1.stopped && ok2 && r2.stopped
}, testutil.WaitShort, testutil.IntervalFast)
require.NoError(t, pd.Close())
}
func TestPortableDesktop_StartRecording_ReturnsErrDesktopClosed(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
clk := quartz.NewReal()
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: clk,
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(clk.Now().UnixNano())
// Start and close the desktop so it's in the closed state.
ctx := t.Context()
_, err := pd.Start(ctx)
require.NoError(t, err)
require.NoError(t, pd.Close())
// StartRecording should now return ErrDesktopClosed.
err = pd.StartRecording(ctx, uuid.New().String())
require.ErrorIs(t, err, ErrDesktopClosed)
}
func TestPortableDesktop_Start_ReturnsErrDesktopClosed(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
rec := &recordedExecer{
scripts: map[string]string{
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
},
}
pd := &portableDesktop{
logger: logger,
execer: rec,
scriptBinDir: t.TempDir(),
clock: quartz.NewReal(),
binPath: "portabledesktop",
recordings: make(map[string]*recordingProcess),
}
pd.lastDesktopActionAt.Store(pd.clock.Now().UnixNano())
ctx := t.Context()
_, err := pd.Start(ctx)
require.NoError(t, err)
require.NoError(t, pd.Close())
_, err = pd.Start(ctx)
require.ErrorIs(t, err, ErrDesktopClosed)
}
@@ -1,12 +0,0 @@
//go:build !windows
package agentdesktop
import "os"
// interruptRecordingProcess sends a SIGINT to the recording process
// for graceful shutdown. On Unix, os.Interrupt is delivered as
// SIGINT which lets the recorder finalize the MP4 container.
func interruptRecordingProcess(p *os.Process) error {
return p.Signal(os.Interrupt)
}
@@ -1,10 +0,0 @@
package agentdesktop
import "os"
// interruptRecordingProcess kills the recording process directly
// because os.Process.Signal(os.Interrupt) is not supported on
// Windows and returns an error without delivering a signal.
func interruptRecordingProcess(p *os.Process) error {
return p.Kill()
}
+12 -39
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
@@ -69,40 +70,16 @@ func NewManager(logger slog.Logger) *Manager {
}
}
// 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
// Connect discovers .mcp.json in dir and connects to all
// configured servers. Failed servers are logged and skipped.
func (m *Manager) Connect(ctx context.Context, dir string) error {
path := filepath.Join(dir, ".mcp.json")
configs, err := ParseConfig(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
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
return xerrors.Errorf("parse mcp config: %w", err)
}
// Connect to servers in parallel without holding the
@@ -118,7 +95,7 @@ func (m *Manager) Connect(ctx context.Context, mcpConfigFiles []string) error {
connected []connectedServer
)
var eg errgroup.Group
for _, cfg := range allConfigs {
for _, cfg := range configs {
eg.Go(func() error {
c, err := m.connectServer(ctx, cfg)
if err != nil {
@@ -187,11 +164,7 @@ func (*Manager) connectServer(ctx context.Context, cfg ServerConfig) (*client.Cl
connectCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
// Use the parent ctx (not connectCtx) so the subprocess outlives
// the connect/initialize handshake. connectCtx bounds only the
// Initialize call below. The subprocess is cleaned up when the
// Manager is closed or ctx is canceled.
if err := c.Start(ctx); err != nil {
if err := c.Start(connectCtx); err != nil {
_ = c.Close()
return nil, xerrors.Errorf("start %q: %w", cfg.Name, err)
}
-121
View File
@@ -1,11 +1,6 @@
package agentmcp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/mark3labs/mcp-go/mcp"
@@ -13,7 +8,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
)
func TestSplitToolName(t *testing.T) {
@@ -199,118 +193,3 @@ func TestConvertResult(t *testing.T) {
})
}
}
// TestConnectServer_StdioProcessSurvivesConnect verifies that a stdio MCP
// server subprocess remains alive after connectServer returns. This is a
// regression test for a bug where the subprocess was tied to a short-lived
// connectCtx and killed as soon as the context was canceled.
func TestConnectServer_StdioProcessSurvivesConnect(t *testing.T) {
t.Parallel()
if os.Getenv("TEST_MCP_FAKE_SERVER") == "1" {
// Child process: act as a minimal MCP server over stdio.
runFakeMCPServer()
return
}
// Get the path to the test binary so we can re-exec ourselves
// as a fake MCP server subprocess.
testBin, err := os.Executable()
require.NoError(t, err)
cfg := ServerConfig{
Name: "fake",
Transport: "stdio",
Command: testBin,
Args: []string{"-test.run=^TestConnectServer_StdioProcessSurvivesConnect$"},
Env: map[string]string{"TEST_MCP_FAKE_SERVER": "1"},
}
ctx := testutil.Context(t, testutil.WaitLong)
m := &Manager{}
client, err := m.connectServer(ctx, cfg)
require.NoError(t, err, "connectServer should succeed")
t.Cleanup(func() { _ = client.Close() })
// At this point connectServer has returned and its internal
// connectCtx has been canceled. The subprocess must still be
// alive. Verify by listing tools (requires a live server).
listCtx, listCancel := context.WithTimeout(ctx, testutil.WaitShort)
defer listCancel()
result, err := client.ListTools(listCtx, mcp.ListToolsRequest{})
require.NoError(t, err, "ListTools should succeed — server must be alive after connect")
require.Len(t, result.Tools, 1)
assert.Equal(t, "echo", result.Tools[0].Name)
}
// runFakeMCPServer implements a minimal JSON-RPC / MCP server over
// stdin/stdout, just enough for initialize + tools/list.
func runFakeMCPServer() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Bytes()
var req struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(line, &req); err != nil {
continue
}
var resp any
switch req.Method {
case "initialize":
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{
"tools": map[string]any{},
},
"serverInfo": map[string]any{
"name": "fake-server",
"version": "0.0.1",
},
},
}
case "notifications/initialized":
// No response needed for notifications.
continue
case "tools/list":
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"tools": []map[string]any{
{
"name": "echo",
"description": "echoes input",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
},
}
default:
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"error": map[string]any{
"code": -32601,
"message": "method not found",
},
}
}
out, err := json.Marshal(resp)
if err != nil {
continue
}
_, _ = fmt.Fprintf(os.Stdout, "%s\n", out)
}
}
+19 -28
View File
@@ -3,13 +3,11 @@
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
"defaultBranch": "main",
},
"files": {
// static/*.html are Go templates with {{ }} directives that
// Biome's HTML parser does not support.
"includes": ["**", "!**/pnpm-lock.yaml", "!**/static/*.html"],
"ignoreUnknown": true
"includes": ["**", "!**/pnpm-lock.yaml"],
"ignoreUnknown": true,
},
"linter": {
"rules": {
@@ -17,7 +15,7 @@
"noSvgWithoutTitle": "off",
"useButtonType": "off",
"useSemanticElements": "off",
"noStaticElementInteractions": "off"
"noStaticElementInteractions": "off",
},
"correctness": {
"noUnusedImports": "warn",
@@ -26,9 +24,9 @@
"noUnusedVariables": {
"level": "warn",
"options": {
"ignoreRestSiblings": true
}
}
"ignoreRestSiblings": true,
},
},
},
"style": {
"noNonNullAssertion": "off",
@@ -49,7 +47,7 @@
"paths": {
"react": {
"message": "React 19 no longer requires forwardRef. Use ref as a prop instead.",
"importNames": ["forwardRef"]
"importNames": ["forwardRef"],
},
// "@mui/material/Alert": "Use components/Alert/Alert instead.",
// "@mui/material/AlertTitle": "Use components/Alert/Alert instead.",
@@ -117,10 +115,10 @@
"@emotion/styled": "Use Tailwind CSS instead.",
// "@emotion/cache": "Use Tailwind CSS instead.",
// "components/Stack/Stack": "Use Tailwind flex utilities instead (e.g., <div className='flex flex-col gap-4'>).",
"lodash": "Use lodash/<name> instead."
}
}
}
"lodash": "Use lodash/<name> instead.",
},
},
},
},
"suspicious": {
"noArrayIndexKey": "off",
@@ -131,21 +129,14 @@
"noConsole": {
"level": "error",
"options": {
"allow": ["error", "info", "warn"]
}
}
"allow": ["error", "info", "warn"],
},
},
},
"complexity": {
"noImportantStyles": "off" // TODO: check and fix !important styles
}
}
"noImportantStyles": "off", // TODO: check and fix !important styles
},
},
},
"css": {
"parser": {
// Biome 2.3+ requires opt-in for @apply and other
// Tailwind directives.
"tailwindDirectives": true
}
},
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
}
+6 -33
View File
@@ -17,7 +17,6 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"gopkg.in/natefinch/lumberjack.v2"
@@ -273,14 +272,11 @@ func workspaceAgent() *serpent.Command {
logger.Info(ctx, "agent devcontainer detection not enabled")
}
reinitCtx, reinitCancel := context.WithCancel(ctx)
defer reinitCancel()
reinitEvents := agentsdk.WaitForReinitLoop(reinitCtx, logger, client)
reinitEvents := agentsdk.WaitForReinitLoop(ctx, logger, client)
var (
lastOwnerID uuid.UUID
lastErr error
mustExit bool
lastErr error
mustExit bool
)
for {
prometheusRegistry := prometheus.NewRegistry()
@@ -347,32 +343,9 @@ func workspaceAgent() *serpent.Command {
case <-ctx.Done():
logger.Info(ctx, "agent shutting down", slog.Error(context.Cause(ctx)))
mustExit = true
case event, ok := <-reinitEvents:
switch {
case !ok:
// Channel closed — the reinit loop exited
// (terminal 409 or context expired). Keep
// running the current agent until the parent
// context is canceled.
logger.Info(ctx, "reinit channel closed, running without reinit capability")
reinitEvents = nil
<-ctx.Done()
mustExit = true
case event.OwnerID != uuid.Nil && event.OwnerID == lastOwnerID:
// Duplicate reinit for same owner — already
// reinitialized. Cancel the reinit loop
// goroutine and keep the current agent.
logger.Info(ctx, "skipping redundant reinit, owner unchanged",
slog.F("owner_id", event.OwnerID))
reinitCancel()
reinitEvents = nil
<-ctx.Done()
mustExit = true
default:
lastOwnerID = event.OwnerID
logger.Info(ctx, "agent received instruction to reinitialize",
slog.F("workspace_id", event.WorkspaceID), slog.F("reason", event.Reason))
}
case event := <-reinitEvents:
logger.Info(ctx, "agent received instruction to reinitialize",
slog.F("workspace_id", event.WorkspaceID), slog.F("reason", event.Reason))
}
lastErr = agnt.Close()
+1 -1
View File
@@ -104,7 +104,7 @@ func (b *Builder) Build(inv *serpent.Invocation) (log slog.Logger, closeLog func
addSinkIfProvided := func(sinkFn func(io.Writer) slog.Sink, loc string) error {
switch loc {
case "", "/dev/null":
case "":
case "/dev/stdout":
sinks = append(sinks, sinkFn(inv.Stdout))
-3
View File
@@ -1401,9 +1401,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
// Setup our workspace agent connection.
config := workspacetraffic.Config{
AgentID: agent.ID,
WorkspaceID: ws.ID,
WorkspaceName: ws.Name,
AgentName: agent.Name,
BytesPerTick: bytesPerTick,
Duration: strategy.timeout,
TickInterval: tickInterval,
-31
View File
@@ -165,37 +165,6 @@ 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)
+8 -9
View File
@@ -11,16 +11,17 @@ import (
func (*RootCmd) syncWant(socketPath *string) *serpent.Command {
cmd := &serpent.Command{
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.",
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.",
Handler: func(i *serpent.Invocation) error {
ctx := i.Context()
if len(i.Args) < 2 {
return xerrors.New("at least two arguments are required: unit and one or more depends-on")
if len(i.Args) != 2 {
return xerrors.New("exactly two arguments are required: unit and depends-on")
}
dependentUnit := unit.ID(i.Args[0])
dependsOn := unit.ID(i.Args[1])
opts := []agentsocket.Option{}
if *socketPath != "" {
@@ -33,10 +34,8 @@ func (*RootCmd) syncWant(socketPath *string) *serpent.Command {
}
defer client.Close()
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)
}
if err := client.SyncWant(ctx, dependentUnit, dependsOn); err != nil {
return xerrors.Errorf("declare dependency failed: %w", err)
}
cliui.Info(i.Stdout, "Success")
+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 other units completing before it
want Declare that a unit depends on another unit 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> [depends-on...]
coder exp sync want <unit> <depends-on>
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
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.
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.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -85,7 +85,7 @@ func (a *ConnLogAPI) ReportConnection(ctx context.Context, req *agentproto.Repor
AgentName: a.AgentName,
Type: connectionType,
Code: code,
IP: logIP,
Ip: logIP,
ConnectionID: uuid.NullUUID{
UUID: connectionID,
Valid: true,
+1 -1
View File
@@ -152,7 +152,7 @@ func TestConnectionLog(t *testing.T) {
Int32: tt.status,
Valid: *tt.action == agentproto.Connection_DISCONNECT,
},
IP: expectedIP,
Ip: expectedIP,
Type: agentProtoConnectionTypeToConnectionLog(t, *tt.typ),
DisconnectReason: sql.NullString{
String: tt.reason,
+14 -62
View File
@@ -10205,26 +10205,12 @@ const docTemplate = `{
],
"summary": "Get workspace agent reinitialization",
"operationId": "get-workspace-agent-reinitialization",
"parameters": [
{
"type": "boolean",
"description": "Opt in to durable reinit checks",
"name": "wait",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/agentsdk.ReinitializationEvent"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
}
},
"security": [
@@ -12661,16 +12647,11 @@ const docTemplate = `{
"agentsdk.ReinitializationEvent": {
"type": "object",
"properties": {
"owner_id": {
"type": "string",
"format": "uuid"
},
"reason": {
"$ref": "#/definitions/agentsdk.ReinitializationReason"
},
"workspace_id": {
"type": "string",
"format": "uuid"
"workspaceID": {
"type": "string"
}
}
},
@@ -12913,9 +12894,6 @@ const docTemplate = `{
"provider": {
"type": "string"
},
"provider_name": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
@@ -13133,12 +13111,6 @@ const docTemplate = `{
"codersdk.AIBridgeSessionThreadsTokenUsage": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"input_tokens": {
"type": "integer"
},
@@ -13154,12 +13126,6 @@ const docTemplate = `{
"codersdk.AIBridgeSessionTokenUsageSummary": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"input_tokens": {
"type": "integer"
},
@@ -13206,12 +13172,6 @@ const docTemplate = `{
"codersdk.AIBridgeTokenUsage": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -13485,6 +13445,11 @@ const docTemplate = `{
"chat:delete",
"chat:read",
"chat:update",
"chat_automation:*",
"chat_automation:create",
"chat_automation:delete",
"chat_automation:read",
"chat_automation:update",
"coder:all",
"coder:apikeys.manage_self",
"coder:application_connect",
@@ -13694,6 +13659,11 @@ const docTemplate = `{
"APIKeyScopeChatDelete",
"APIKeyScopeChatRead",
"APIKeyScopeChatUpdate",
"APIKeyScopeChatAutomationAll",
"APIKeyScopeChatAutomationCreate",
"APIKeyScopeChatAutomationDelete",
"APIKeyScopeChatAutomationRead",
"APIKeyScopeChatAutomationUpdate",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApikeysManageSelf",
"APIKeyScopeCoderApplicationConnect",
@@ -14175,9 +14145,6 @@ const docTemplate = `{
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
@@ -14499,9 +14466,6 @@ const docTemplate = `{
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
@@ -14589,17 +14553,6 @@ const docTemplate = `{
}
}
},
"codersdk.CreateFirstUserOnboardingInfo": {
"type": "object",
"properties": {
"newsletter_marketing": {
"type": "boolean"
},
"newsletter_releases": {
"type": "boolean"
}
}
},
"codersdk.CreateFirstUserRequest": {
"type": "object",
"required": [
@@ -14614,9 +14567,6 @@ const docTemplate = `{
"name": {
"type": "string"
},
"onboarding_info": {
"$ref": "#/definitions/codersdk.CreateFirstUserOnboardingInfo"
},
"password": {
"type": "string"
},
@@ -19098,6 +19048,7 @@ const docTemplate = `{
"audit_log",
"boundary_usage",
"chat",
"chat_automation",
"connection_log",
"crypto_key",
"debug_info",
@@ -19144,6 +19095,7 @@ const docTemplate = `{
"ResourceAuditLog",
"ResourceBoundaryUsage",
"ResourceChat",
"ResourceChatAutomation",
"ResourceConnectionLog",
"ResourceCryptoKey",
"ResourceDebugInfo",
+14 -62
View File
@@ -9038,26 +9038,12 @@
"tags": ["Agents"],
"summary": "Get workspace agent reinitialization",
"operationId": "get-workspace-agent-reinitialization",
"parameters": [
{
"type": "boolean",
"description": "Opt in to durable reinit checks",
"name": "wait",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/agentsdk.ReinitializationEvent"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/codersdk.Response"
}
}
},
"security": [
@@ -11243,16 +11229,11 @@
"agentsdk.ReinitializationEvent": {
"type": "object",
"properties": {
"owner_id": {
"type": "string",
"format": "uuid"
},
"reason": {
"$ref": "#/definitions/agentsdk.ReinitializationReason"
},
"workspace_id": {
"type": "string",
"format": "uuid"
"workspaceID": {
"type": "string"
}
}
},
@@ -11491,9 +11472,6 @@
"provider": {
"type": "string"
},
"provider_name": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
@@ -11711,12 +11689,6 @@
"codersdk.AIBridgeSessionThreadsTokenUsage": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"input_tokens": {
"type": "integer"
},
@@ -11732,12 +11704,6 @@
"codersdk.AIBridgeSessionTokenUsageSummary": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"input_tokens": {
"type": "integer"
},
@@ -11784,12 +11750,6 @@
"codersdk.AIBridgeTokenUsage": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -12055,6 +12015,11 @@
"chat:delete",
"chat:read",
"chat:update",
"chat_automation:*",
"chat_automation:create",
"chat_automation:delete",
"chat_automation:read",
"chat_automation:update",
"coder:all",
"coder:apikeys.manage_self",
"coder:application_connect",
@@ -12264,6 +12229,11 @@
"APIKeyScopeChatDelete",
"APIKeyScopeChatRead",
"APIKeyScopeChatUpdate",
"APIKeyScopeChatAutomationAll",
"APIKeyScopeChatAutomationCreate",
"APIKeyScopeChatAutomationDelete",
"APIKeyScopeChatAutomationRead",
"APIKeyScopeChatAutomationUpdate",
"APIKeyScopeCoderAll",
"APIKeyScopeCoderApikeysManageSelf",
"APIKeyScopeCoderApplicationConnect",
@@ -12739,9 +12709,6 @@
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
@@ -13042,9 +13009,6 @@
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
@@ -13129,17 +13093,6 @@
}
}
},
"codersdk.CreateFirstUserOnboardingInfo": {
"type": "object",
"properties": {
"newsletter_marketing": {
"type": "boolean"
},
"newsletter_releases": {
"type": "boolean"
}
}
},
"codersdk.CreateFirstUserRequest": {
"type": "object",
"required": ["email", "password", "username"],
@@ -13150,9 +13103,6 @@
"name": {
"type": "string"
},
"onboarding_info": {
"$ref": "#/definitions/codersdk.CreateFirstUserOnboardingInfo"
},
"password": {
"type": "string"
},
@@ -17470,6 +17420,7 @@
"audit_log",
"boundary_usage",
"chat",
"chat_automation",
"connection_log",
"crypto_key",
"debug_info",
@@ -17516,6 +17467,7 @@
"ResourceAuditLog",
"ResourceBoundaryUsage",
"ResourceChat",
"ResourceChatAutomation",
"ResourceConnectionLog",
"ResourceCryptoKey",
"ResourceDebugInfo",
-15
View File
@@ -582,20 +582,5 @@ 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,55 +394,6 @@ 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 -8
View File
@@ -26,11 +26,6 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// Limit the count query to avoid a slow sequential scan due to joins
// on a large table. Set to 0 to disable capping (but also see the note
// in the SQL query).
const auditLogCountCap = 2000
// @Summary Get audit logs
// @ID get-audit-logs
// @Security CoderSessionToken
@@ -71,7 +66,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
countFilter.Username = ""
}
countFilter.CountCap = auditLogCountCap
// Use the same filters to count the number of audit logs
count, err := api.Database.CountAuditLogs(ctx, countFilter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
@@ -86,7 +81,6 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: []codersdk.AuditLog{},
Count: 0,
CountCap: auditLogCountCap,
})
return
}
@@ -104,7 +98,6 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: api.convertAuditLogs(ctx, dblogs),
Count: count,
CountCap: auditLogCountCap,
})
}
+1 -10
View File
@@ -168,7 +168,6 @@ type Options struct {
ConnectionLogger connectionlog.ConnectionLogger
AgentConnectionUpdateFrequency time.Duration
AgentInactiveDisconnectTimeout time.Duration
ChatdInstructionLookupTimeout time.Duration
AWSCertificates awsidentity.Certificates
Authorizer rbac.Authorizer
AzureCertificates x509.VerifyOptions
@@ -783,10 +782,9 @@ func New(options *Options) *API {
ReplicaID: api.ID,
SubscribeFn: options.ChatSubscribeFn,
MaxChatsPerAcquire: int32(maxChatsPerAcquire), //nolint:gosec // maxChatsPerAcquire is clamped to int32 range above.
ProviderAPIKeys: ChatProviderAPIKeysFromDeploymentValues(options.DeploymentValues),
ProviderAPIKeys: chatProviderAPIKeysFromDeploymentValues(options.DeploymentValues),
AgentConn: api.agentProvider.AgentConn,
AgentInactiveDisconnectTimeout: api.AgentInactiveDisconnectTimeout,
InstructionLookupTimeout: options.ChatdInstructionLookupTimeout,
CreateWorkspace: api.chatCreateWorkspace,
StartWorkspace: api.chatStartWorkspace,
Pubsub: options.Pubsub,
@@ -1223,13 +1221,6 @@ func New(options *Options) *API {
r.Delete("/", api.deleteChatUsageLimitGroupOverride)
})
})
r.Route("/user-provider-configs", func(r chi.Router) {
r.Get("/", api.listUserChatProviderConfigs)
r.Route("/{providerConfig}", func(r chi.Router) {
r.Put("/", api.upsertUserChatProviderKey)
r.Delete("/", api.deleteUserChatProviderKey)
})
})
r.Route("/{chat}", func(r chi.Router) {
r.Use(httpmw.ExtractChatParam(options.Database))
r.Get("/", api.getChat)
+6 -8
View File
@@ -149,13 +149,12 @@ type Options struct {
OneTimePasscodeValidityPeriod time.Duration
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
IncludeProvisionerDaemon bool
ChatdInstructionLookupTimeout time.Duration
ProvisionerDaemonVersion string
ProvisionerDaemonTags map[string]string
MetricsCacheRefreshInterval time.Duration
AgentStatsRefreshInterval time.Duration
DeploymentValues *codersdk.DeploymentValues
IncludeProvisionerDaemon bool
ProvisionerDaemonVersion string
ProvisionerDaemonTags map[string]string
MetricsCacheRefreshInterval time.Duration
AgentStatsRefreshInterval time.Duration
DeploymentValues *codersdk.DeploymentValues
// Set update check options to enable update check.
UpdateCheckOptions *updatecheck.Options
@@ -576,7 +575,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
// Force a long disconnection timeout to ensure
// agents are not marked as disconnected during slow tests.
AgentInactiveDisconnectTimeout: testutil.WaitShort,
ChatdInstructionLookupTimeout: options.ChatdInstructionLookupTimeout,
AccessURL: accessURL,
AppHostname: options.AppHostname,
AppHostnameRegex: appHostnameRegex,
+2 -2
View File
@@ -90,8 +90,8 @@ func (m *FakeConnectionLogger) Contains(t testing.TB, expected database.UpsertCo
t.Logf("connection log %d: expected Code %d, got %d", idx+1, expected.Code.Int32, cl.Code.Int32)
continue
}
if expected.IP.Valid && cl.IP.IPNet.String() != expected.IP.IPNet.String() {
t.Logf("connection log %d: expected IP %s, got %s", idx+1, expected.IP.IPNet, cl.IP.IPNet)
if expected.Ip.Valid && cl.Ip.IPNet.String() != expected.Ip.IPNet.String() {
t.Logf("connection log %d: expected IP %s, got %s", idx+1, expected.Ip.IPNet, cl.Ip.IPNet)
continue
}
if expected.UserAgent.Valid && cl.UserAgent.String != expected.UserAgent.String {
+5 -2
View File
@@ -7,10 +7,14 @@ type CheckConstraint string
// CheckConstraint enums.
const (
CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys
CheckChatAutomationEventsChatExclusivity CheckConstraint = "chat_automation_events_chat_exclusivity" // chat_automation_events
CheckChatAutomationTriggersCronFields CheckConstraint = "chat_automation_triggers_cron_fields" // chat_automation_triggers
CheckChatAutomationTriggersWebhookFields CheckConstraint = "chat_automation_triggers_webhook_fields" // chat_automation_triggers
CheckChatAutomationsMaxChatCreatesPerHourCheck CheckConstraint = "chat_automations_max_chat_creates_per_hour_check" // chat_automations
CheckChatAutomationsMaxMessagesPerHourCheck CheckConstraint = "chat_automations_max_messages_per_hour_check" // chat_automations
CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs
CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs
CheckChatProvidersProviderCheck CheckConstraint = "chat_providers_provider_check" // chat_providers
CheckValidCredentialPolicy CheckConstraint = "valid_credential_policy" // chat_providers
CheckChatUsageLimitConfigDefaultLimitMicrosCheck CheckConstraint = "chat_usage_limit_config_default_limit_micros_check" // chat_usage_limit_config
CheckChatUsageLimitConfigPeriodCheck CheckConstraint = "chat_usage_limit_config_period_check" // chat_usage_limit_config
CheckChatUsageLimitConfigSingletonCheck CheckConstraint = "chat_usage_limit_config_singleton_check" // chat_usage_limit_config
@@ -33,5 +37,4 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckUserChatProviderKeysAPIKeyCheck CheckConstraint = "user_chat_provider_keys_api_key_check" // user_chat_provider_keys
)
+30 -55
View File
@@ -999,16 +999,15 @@ 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,
ProviderName: interception.ProviderName,
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,
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
@@ -1037,10 +1036,8 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess
StartedAt: row.StartedAt,
Threads: row.Threads,
TokenUsageSummary: codersdk.AIBridgeSessionTokenUsageSummary{
InputTokens: row.InputTokens,
OutputTokens: row.OutputTokens,
CacheReadInputTokens: row.CacheReadInputTokens,
CacheWriteInputTokens: row.CacheWriteInputTokens,
InputTokens: row.InputTokens,
OutputTokens: row.OutputTokens,
},
}
// Ensure non-nil slices for JSON serialization.
@@ -1064,15 +1061,13 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess
func AIBridgeTokenUsage(usage database.AIBridgeTokenUsage) codersdk.AIBridgeTokenUsage {
return codersdk.AIBridgeTokenUsage{
ID: usage.ID,
InterceptionID: usage.InterceptionID,
ProviderResponseID: usage.ProviderResponseID,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
CacheReadInputTokens: usage.CacheReadInputTokens,
CacheWriteInputTokens: usage.CacheWriteInputTokens,
Metadata: jsonOrEmptyMap(usage.Metadata),
CreatedAt: usage.CreatedAt,
ID: usage.ID,
InterceptionID: usage.InterceptionID,
ProviderResponseID: usage.ProviderResponseID,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
Metadata: jsonOrEmptyMap(usage.Metadata),
CreatedAt: usage.CreatedAt,
}
}
@@ -1183,11 +1178,9 @@ func AIBridgeSessionThreads(
PageStartedAt: pageStartedAt,
PageEndedAt: pageEndedAt,
TokenUsageSummary: codersdk.AIBridgeSessionThreadsTokenUsage{
InputTokens: session.InputTokens,
OutputTokens: session.OutputTokens,
CacheReadInputTokens: session.CacheReadInputTokens,
CacheWriteInputTokens: session.CacheWriteInputTokens,
Metadata: sessionTokenMeta,
InputTokens: session.InputTokens,
OutputTokens: session.OutputTokens,
Metadata: sessionTokenMeta,
},
Threads: threads,
}
@@ -1320,19 +1313,17 @@ func buildAIBridgeThread(
// aggregateTokenUsage sums token usage rows and aggregates metadata.
func aggregateTokenUsage(tokens []database.AIBridgeTokenUsage) codersdk.AIBridgeSessionThreadsTokenUsage {
var inputTokens, outputTokens, cacheRead, cacheWrite int64
var inputTokens, outputTokens int64
for _, tu := range tokens {
inputTokens += tu.InputTokens
outputTokens += tu.OutputTokens
cacheRead += tu.CacheReadInputTokens
cacheWrite += tu.CacheWriteInputTokens
// TODO: once https://github.com/coder/aibridge/issues/150 lands we
// should aggregate the other token types.
}
return codersdk.AIBridgeSessionThreadsTokenUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheReadInputTokens: cacheRead,
CacheWriteInputTokens: cacheWrite,
Metadata: aggregateTokenMetadata(tokens),
InputTokens: inputTokens,
OutputTokens: outputTokens,
Metadata: aggregateTokenMetadata(tokens),
}
}
@@ -1528,10 +1519,7 @@ func nullInt64Ptr(v sql.NullInt64) *int64 {
// 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.
// When diffStatus is non-nil the response includes diff metadata.
// When files is non-empty the response includes file metadata;
// pass nil to omit the files field (e.g. list endpoints).
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database.GetChatFileMetadataByChatIDRow) codersdk.Chat {
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
mcpServerIDs := c.MCPServerIDs
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
@@ -1584,19 +1572,6 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
convertedDiffStatus := ChatDiffStatus(c.ID, diffStatus)
chat.DiffStatus = &convertedDiffStatus
}
if len(files) > 0 {
chat.Files = make([]codersdk.ChatFileMetadata, 0, len(files))
for _, row := range files {
chat.Files = append(chat.Files, codersdk.ChatFileMetadata{
ID: row.ID,
OwnerID: row.OwnerID,
OrganizationID: row.OrganizationID,
Name: row.Name,
MimeType: row.Mimetype,
CreatedAt: row.CreatedAt,
})
}
}
if c.LastInjectedContext.Valid {
var parts []codersdk.ChatMessagePart
// Internal fields are stripped at write time in
@@ -1620,9 +1595,9 @@ func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]da
for i, row := range rows {
diffStatus, ok := diffStatusesByChatID[row.Chat.ID]
if ok {
result[i] = Chat(row.Chat, &diffStatus, nil)
result[i] = Chat(row.Chat, &diffStatus)
} else {
result[i] = Chat(row.Chat, nil, nil)
result[i] = Chat(row.Chat, nil)
if diffStatusesByChatID != nil {
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
+9 -131
View File
@@ -259,13 +259,11 @@ func TestAIBridgeInterception(t *testing.T) {
},
tokenUsages: []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
InputTokens: 100,
OutputTokens: 200,
CacheReadInputTokens: 50,
CacheWriteInputTokens: 10,
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
InputTokens: 100,
OutputTokens: 200,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache":"hit"}`),
Valid: true,
@@ -415,8 +413,6 @@ func TestAIBridgeInterception(t *testing.T) {
require.Equal(t, tu.ProviderResponseID, result.TokenUsages[i].ProviderResponseID)
require.Equal(t, tu.InputTokens, result.TokenUsages[i].InputTokens)
require.Equal(t, tu.OutputTokens, result.TokenUsages[i].OutputTokens)
require.Equal(t, tu.CacheReadInputTokens, result.TokenUsages[i].CacheReadInputTokens)
require.Equal(t, tu.CacheWriteInputTokens, result.TokenUsages[i].CacheWriteInputTokens)
}
// Verify user prompts are converted correctly.
@@ -561,26 +557,14 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
ChatID: input.ID,
}
fileRows := []database.GetChatFileMetadataByChatIDRow{
{
ID: uuid.New(),
OwnerID: input.OwnerID,
OrganizationID: uuid.New(),
Name: "test.png",
Mimetype: "image/png",
CreatedAt: now,
},
}
got := db2sdk.Chat(input, diffStatus, fileRows)
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. Warnings is a transient
// field populated by handlers, not the converter. Both are
// expected to remain zero here.
skip := map[string]bool{"HasUnread": true, "Warnings": true}
// 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] {
@@ -593,112 +577,6 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
}
}
func TestChat_FileMetadataConversion(t *testing.T) {
t.Parallel()
ownerID := uuid.New()
orgID := uuid.New()
fileID := uuid.New()
now := dbtime.Now()
chat := database.Chat{
ID: uuid.New(),
OwnerID: ownerID,
LastModelConfigID: uuid.New(),
Title: "file metadata test",
Status: database.ChatStatusWaiting,
CreatedAt: now,
UpdatedAt: now,
}
rows := []database.GetChatFileMetadataByChatIDRow{
{
ID: fileID,
OwnerID: ownerID,
OrganizationID: orgID,
Name: "screenshot.png",
Mimetype: "image/png",
CreatedAt: now,
},
}
result := db2sdk.Chat(chat, nil, rows)
require.Len(t, result.Files, 1)
f := result.Files[0]
require.Equal(t, fileID, f.ID)
require.Equal(t, ownerID, f.OwnerID, "OwnerID must be mapped from DB row")
require.Equal(t, orgID, f.OrganizationID, "OrganizationID must be mapped from DB row")
require.Equal(t, "screenshot.png", f.Name)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, now, f.CreatedAt)
// Verify JSON serialization uses snake_case for mime_type.
data, err := json.Marshal(f)
require.NoError(t, err)
require.Contains(t, string(data), `"mime_type"`)
require.NotContains(t, string(data), `"mimetype"`)
}
func TestChat_NilFilesOmitted(t *testing.T) {
t.Parallel()
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
LastModelConfigID: uuid.New(),
Title: "no files",
Status: database.ChatStatusWaiting,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
}
result := db2sdk.Chat(chat, nil, nil)
require.Empty(t, result.Files)
}
func TestChat_MultipleFiles(t *testing.T) {
t.Parallel()
now := dbtime.Now()
file1 := uuid.New()
file2 := uuid.New()
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
LastModelConfigID: uuid.New(),
Title: "multi file test",
Status: database.ChatStatusWaiting,
CreatedAt: now,
UpdatedAt: now,
}
rows := []database.GetChatFileMetadataByChatIDRow{
{
ID: file1,
OwnerID: chat.OwnerID,
OrganizationID: uuid.New(),
Name: "a.png",
Mimetype: "image/png",
CreatedAt: now,
},
{
ID: file2,
OwnerID: chat.OwnerID,
OrganizationID: uuid.New(),
Name: "b.txt",
Mimetype: "text/plain",
CreatedAt: now,
},
}
result := db2sdk.Chat(chat, nil, rows)
require.Len(t, result.Files, 2)
require.Equal(t, "a.png", result.Files[0].Name)
require.Equal(t, "b.txt", result.Files[1].Name)
}
func TestChatQueuedMessage_MalformedContent(t *testing.T) {
t.Parallel()
+259 -93
View File
@@ -1627,13 +1627,6 @@ func (q *querier) BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg datab
return q.db.BatchUpdateWorkspaceNextStartAt(ctx, arg)
}
func (q *querier) BatchUpsertConnectionLogs(ctx context.Context, arg database.BatchUpsertConnectionLogsParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
return err
}
return q.db.BatchUpsertConnectionLogs(ctx, arg)
}
func (q *querier) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceNotificationMessage); err != nil {
return 0, err
@@ -1701,6 +1694,13 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error {
return q.db.CleanTailnetTunnels(ctx)
}
func (q *querier) CleanupDeletedMCPServerIDsFromChatAutomations(ctx context.Context) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatAutomation); err != nil {
return err
}
return q.db.CleanupDeletedMCPServerIDsFromChatAutomations(ctx)
}
func (q *querier) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return err
@@ -1738,6 +1738,28 @@ func (q *querier) CountAuditLogs(ctx context.Context, arg database.CountAuditLog
return q.db.CountAuthorizedAuditLogs(ctx, arg, prep)
}
func (q *querier) CountChatAutomationChatCreatesInWindow(ctx context.Context, arg database.CountChatAutomationChatCreatesInWindowParams) (int64, error) {
automation, err := q.db.GetChatAutomationByID(ctx, arg.AutomationID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, automation); err != nil {
return 0, err
}
return q.db.CountChatAutomationChatCreatesInWindow(ctx, arg)
}
func (q *querier) CountChatAutomationMessagesInWindow(ctx context.Context, arg database.CountChatAutomationMessagesInWindowParams) (int64, error) {
automation, err := q.db.GetChatAutomationByID(ctx, arg.AutomationID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, automation); err != nil {
return 0, err
}
return q.db.CountChatAutomationMessagesInWindow(ctx, arg)
}
func (q *querier) CountConnectionLogs(ctx context.Context, arg database.CountConnectionLogsParams) (int64, error) {
// Just like the actual query, shortcut if the user is an owner.
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
@@ -1849,6 +1871,28 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u
return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID)
}
func (q *querier) DeleteChatAutomationByID(ctx context.Context, id uuid.UUID) error {
return deleteQ(q.log, q.auth, q.db.GetChatAutomationByID, q.db.DeleteChatAutomationByID)(ctx, id)
}
// Triggers are sub-resources of an automation. Deleting a trigger
// is a configuration change, so we authorize ActionUpdate on the
// parent rather than ActionDelete.
func (q *querier) DeleteChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) error {
trigger, err := q.db.GetChatAutomationTriggerByID(ctx, id)
if err != nil {
return err
}
automation, err := q.db.GetChatAutomationByID(ctx, trigger.AutomationID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, automation); err != nil {
return err
}
return q.db.DeleteChatAutomationTriggerByID(ctx, id)
}
func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
@@ -2144,23 +2188,17 @@ func (q *querier) DeleteUserChatCompactionThreshold(ctx context.Context, arg dat
return q.db.DeleteUserChatCompactionThreshold(ctx, arg)
}
func (q *querier) DeleteUserChatProviderKey(ctx context.Context, arg database.DeleteUserChatProviderKeyParams) error {
u, err := q.db.GetUserByID(ctx, arg.UserID)
func (q *querier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error {
// First get the secret to check ownership
secret, err := q.GetUserSecret(ctx, id)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil {
return err
}
return q.db.DeleteUserChatProviderKey(ctx, arg)
}
func (q *querier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) error {
obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionDelete, obj); err != nil {
if err := q.authorizeContext(ctx, policy.ActionDelete, secret); err != nil {
return err
}
return q.db.DeleteUserSecretByUserIDAndName(ctx, arg)
return q.db.DeleteUserSecret(ctx, id)
}
func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error {
@@ -2399,6 +2437,16 @@ func (q *querier) GetActiveAISeatCount(ctx context.Context) (int64, error) {
return q.db.GetActiveAISeatCount(ctx)
}
// GetActiveChatAutomationCronTriggers is a system-level query used by
// the cron scheduler. It requires read permission on all automations
// (admin gate) because it fetches triggers across all orgs and owners.
func (q *querier) GetActiveChatAutomationCronTriggers(ctx context.Context) ([]database.GetActiveChatAutomationCronTriggersRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatAutomation.All()); err != nil {
return nil, err
}
return q.db.GetActiveChatAutomationCronTriggers(ctx)
}
func (q *querier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate.All()); err != nil {
return nil, err
@@ -2490,6 +2538,64 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
return q.db.GetAuthorizationUserRoles(ctx, userID)
}
func (q *querier) GetChatAutomationByID(ctx context.Context, id uuid.UUID) (database.ChatAutomation, error) {
return fetch(q.log, q.auth, q.db.GetChatAutomationByID)(ctx, id)
}
func (q *querier) GetChatAutomationEventsByAutomationID(ctx context.Context, arg database.GetChatAutomationEventsByAutomationIDParams) ([]database.ChatAutomationEvent, error) {
automation, err := q.db.GetChatAutomationByID(ctx, arg.AutomationID)
if err != nil {
return nil, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, automation); err != nil {
return nil, err
}
return q.db.GetChatAutomationEventsByAutomationID(ctx, arg)
}
func (q *querier) GetChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) (database.ChatAutomationTrigger, error) {
trigger, err := q.db.GetChatAutomationTriggerByID(ctx, id)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
automation, err := q.db.GetChatAutomationByID(ctx, trigger.AutomationID)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, automation); err != nil {
return database.ChatAutomationTrigger{}, err
}
return trigger, nil
}
func (q *querier) GetChatAutomationTriggersByAutomationID(ctx context.Context, automationID uuid.UUID) ([]database.ChatAutomationTrigger, error) {
automation, err := q.db.GetChatAutomationByID(ctx, automationID)
if err != nil {
return nil, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, automation); err != nil {
return nil, err
}
return q.db.GetChatAutomationTriggersByAutomationID(ctx, automationID)
}
func (q *querier) GetChatAutomations(ctx context.Context, arg database.GetChatAutomationsParams) ([]database.ChatAutomation, error) {
// Shortcut if the caller has broad read access (e.g. site admins
// / owners). The SQL filter is noticeable, so skip it when we
// can.
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatAutomation.All())
if err == nil {
return q.db.GetChatAutomations(ctx, arg)
}
// Fall back to SQL-level row filtering for normal users.
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChatAutomation.Type)
if err != nil {
return nil, xerrors.Errorf("prepare chat automation SQL filter: %w", err)
}
return q.db.GetAuthorizedChatAutomations(ctx, arg, prep)
}
func (q *querier) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) {
return fetch(q.log, q.auth, q.db.GetChatByID)(ctx, id)
}
@@ -2578,10 +2684,6 @@ func (q *querier) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.C
return file, nil
}
func (q *querier) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatFileMetadataByChatID)(ctx, chatID)
}
func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
files, err := q.db.GetChatFilesByIDs(ctx, ids)
if err != nil {
@@ -3652,18 +3754,18 @@ func (q *querier) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]database
return q.db.GetTailnetPeers(ctx, id)
}
func (q *querier) GetTailnetTunnelPeerBindingsBatch(ctx context.Context, ids []uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsBatchRow, error) {
func (q *querier) GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
return nil, err
}
return q.db.GetTailnetTunnelPeerBindingsBatch(ctx, ids)
return q.db.GetTailnetTunnelPeerBindings(ctx, srcID)
}
func (q *querier) GetTailnetTunnelPeerIDsBatch(ctx context.Context, ids []uuid.UUID) ([]database.GetTailnetTunnelPeerIDsBatchRow, error) {
func (q *querier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerIDsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
return nil, err
}
return q.db.GetTailnetTunnelPeerIDsBatch(ctx, ids)
return q.db.GetTailnetTunnelPeerIDs(ctx, srcID)
}
func (q *querier) GetTaskByID(ctx context.Context, id uuid.UUID) (database.Task, error) {
@@ -4041,17 +4143,6 @@ func (q *querier) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID)
return q.db.GetUserChatCustomPrompt(ctx, userID)
}
func (q *querier) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]database.UserChatProviderKey, error) {
u, err := q.db.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil {
return nil, err
}
return q.db.GetUserChatProviderKeys(ctx, userID)
}
func (q *querier) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.UserID.String())); err != nil {
return 0, err
@@ -4123,6 +4214,19 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui
return q.db.GetUserNotificationPreferences(ctx, userID)
}
func (q *querier) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) {
// First get the secret to check ownership
secret, err := q.db.GetUserSecret(ctx, id)
if err != nil {
return database.UserSecret{}, err
}
if err := q.authorizeContext(ctx, policy.ActionRead, secret); err != nil {
return database.UserSecret{}, err
}
return secret, nil
}
func (q *querier) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) {
obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
@@ -4787,6 +4891,36 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams)
return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()), q.db.InsertChat)(ctx, arg)
}
func (q *querier) InsertChatAutomation(ctx context.Context, arg database.InsertChatAutomationParams) (database.ChatAutomation, error) {
return insert(q.log, q.auth, rbac.ResourceChatAutomation.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChatAutomation)(ctx, arg)
}
// Events are append-only records produced by the system when
// triggers fire. We authorize ActionUpdate on the parent
// automation because inserting an event is a side-effect of
// processing the automation, not an independent create action.
func (q *querier) InsertChatAutomationEvent(ctx context.Context, arg database.InsertChatAutomationEventParams) (database.ChatAutomationEvent, error) {
automation, err := q.db.GetChatAutomationByID(ctx, arg.AutomationID)
if err != nil {
return database.ChatAutomationEvent{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, automation); err != nil {
return database.ChatAutomationEvent{}, err
}
return q.db.InsertChatAutomationEvent(ctx, arg)
}
func (q *querier) InsertChatAutomationTrigger(ctx context.Context, arg database.InsertChatAutomationTriggerParams) (database.ChatAutomationTrigger, error) {
automation, err := q.db.GetChatAutomationByID(ctx, arg.AutomationID)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, automation); err != nil {
return database.ChatAutomationTrigger{}, err
}
return q.db.InsertChatAutomationTrigger(ctx, arg)
}
func (q *querier) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
// Authorize create on chat resource scoped to the owner and org.
return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChatFile)(ctx, arg)
@@ -5379,17 +5513,6 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab
return q.db.InsertWorkspaceResourceMetadata(ctx, arg)
}
func (q *querier) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.LinkChatFiles(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 {
@@ -5506,7 +5629,7 @@ func (q *querier) ListUserChatCompactionThresholds(ctx context.Context, userID u
return q.db.ListUserChatCompactionThresholds(ctx, userID)
}
func (q *querier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) {
func (q *querier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) {
obj := rbac.ResourceUserSecret.WithOwner(userID.String())
if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil {
return nil, err
@@ -5514,16 +5637,6 @@ func (q *querier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]data
return q.db.ListUserSecrets(ctx, userID)
}
func (q *querier) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) {
// This query returns decrypted secret values and must only be called
// from system contexts (provisioner, agent manifest). REST API
// handlers should use ListUserSecrets (metadata only).
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.ListUserSecretsWithValues(ctx, userID)
}
func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID)
if err != nil {
@@ -5605,6 +5718,13 @@ func (q *querier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (d
return q.db.PopNextQueuedMessage(ctx, chatID)
}
func (q *querier) PurgeOldChatAutomationEvents(ctx context.Context, arg database.PurgeOldChatAutomationEventsParams) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceChatAutomation.All()); err != nil {
return 0, err
}
return q.db.PurgeOldChatAutomationEvents(ctx, arg)
}
func (q *querier) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
template, err := q.db.GetTemplateByID(ctx, templateID)
if err != nil {
@@ -5751,6 +5871,58 @@ 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) UpdateChatAutomation(ctx context.Context, arg database.UpdateChatAutomationParams) (database.ChatAutomation, error) {
fetchFunc := func(ctx context.Context, arg database.UpdateChatAutomationParams) (database.ChatAutomation, error) {
return q.db.GetChatAutomationByID(ctx, arg.ID)
}
return updateWithReturn(q.log, q.auth, fetchFunc, q.db.UpdateChatAutomation)(ctx, arg)
}
func (q *querier) UpdateChatAutomationTrigger(ctx context.Context, arg database.UpdateChatAutomationTriggerParams) (database.ChatAutomationTrigger, error) {
trigger, err := q.db.GetChatAutomationTriggerByID(ctx, arg.ID)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
automation, err := q.db.GetChatAutomationByID(ctx, trigger.AutomationID)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, automation); err != nil {
return database.ChatAutomationTrigger{}, err
}
return q.db.UpdateChatAutomationTrigger(ctx, arg)
}
func (q *querier) UpdateChatAutomationTriggerLastTriggeredAt(ctx context.Context, arg database.UpdateChatAutomationTriggerLastTriggeredAtParams) error {
trigger, err := q.db.GetChatAutomationTriggerByID(ctx, arg.ID)
if err != nil {
return err
}
automation, err := q.db.GetChatAutomationByID(ctx, trigger.AutomationID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, automation); err != nil {
return err
}
return q.db.UpdateChatAutomationTriggerLastTriggeredAt(ctx, arg)
}
func (q *querier) UpdateChatAutomationTriggerWebhookSecret(ctx context.Context, arg database.UpdateChatAutomationTriggerWebhookSecretParams) (database.ChatAutomationTrigger, error) {
trigger, err := q.db.GetChatAutomationTriggerByID(ctx, arg.ID)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
automation, err := q.db.GetChatAutomationByID(ctx, trigger.AutomationID)
if err != nil {
return database.ChatAutomationTrigger{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, automation); err != nil {
return database.ChatAutomationTrigger{}, err
}
return q.db.UpdateChatAutomationTriggerWebhookSecret(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 {
@@ -5774,15 +5946,15 @@ func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByI
return q.db.UpdateChatByID(ctx, arg)
}
func (q *querier) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
// The batch heartbeat is a system-level operation filtered by
// worker_id. Authorization is enforced by the AsChatd context
// at the call site rather than per-row, because checking each
// row individually would defeat the purpose of batching.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return nil, err
func (q *querier) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return 0, err
}
return q.db.UpdateChatHeartbeats(ctx, arg)
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.UpdateChatHeartbeat(ctx, arg)
}
func (q *querier) UpdateChatLabelsByID(ctx context.Context, arg database.UpdateChatLabelsByIDParams) (database.Chat, error) {
@@ -6490,17 +6662,6 @@ func (q *querier) UpdateUserChatCustomPrompt(ctx context.Context, arg database.U
return q.db.UpdateUserChatCustomPrompt(ctx, arg)
}
func (q *querier) UpdateUserChatProviderKey(ctx context.Context, arg database.UpdateUserChatProviderKeyParams) (database.UserChatProviderKey, error) {
u, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
return database.UserChatProviderKey{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil {
return database.UserChatProviderKey{}, err
}
return q.db.UpdateUserChatProviderKey(ctx, arg)
}
func (q *querier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error {
return deleteQ(q.log, q.auth, q.db.GetUserByID, q.db.UpdateUserDeletedByID)(ctx, id)
}
@@ -6624,12 +6785,17 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo
return q.db.UpdateUserRoles(ctx, arg)
}
func (q *querier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg database.UpdateUserSecretByUserIDAndNameParams) (database.UserSecret, error) {
obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String())
if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil {
func (q *querier) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) {
// First get the secret to check ownership
secret, err := q.db.GetUserSecret(ctx, arg.ID)
if err != nil {
return database.UserSecret{}, err
}
return q.db.UpdateUserSecretByUserIDAndName(ctx, arg)
if err := q.authorizeContext(ctx, policy.ActionUpdate, secret); err != nil {
return database.UserSecret{}, err
}
return q.db.UpdateUserSecret(ctx, arg)
}
func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
@@ -7074,6 +7240,13 @@ func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl strin
return q.db.UpsertChatWorkspaceTTL(ctx, workspaceTtl)
}
func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
return database.ConnectionLog{}, err
}
return q.db.UpsertConnectionLog(ctx, arg)
}
func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
@@ -7216,17 +7389,6 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
return q.db.UpsertTemplateUsageStats(ctx)
}
func (q *querier) UpsertUserChatProviderKey(ctx context.Context, arg database.UpsertUserChatProviderKeyParams) (database.UserChatProviderKey, error) {
u, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
return database.UserChatProviderKey{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil {
return database.UserChatProviderKey{}, err
}
return q.db.UpsertUserChatProviderKey(ctx, arg)
}
func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
@@ -7398,3 +7560,7 @@ func (q *querier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg
func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
return q.GetChats(ctx, arg)
}
func (q *querier) GetAuthorizedChatAutomations(ctx context.Context, arg database.GetChatAutomationsParams, _ rbac.PreparedAuthorized) ([]database.ChatAutomation, error) {
return q.GetChatAutomations(ctx, arg)
}
+264 -93
View File
@@ -338,9 +338,10 @@ func (s *MethodTestSuite) TestAuditLogs() {
}
func (s *MethodTestSuite) TestConnectionLogs() {
s.Run("BatchUpsertConnectionLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.BatchUpsertConnectionLogsParams{}
dbm.EXPECT().BatchUpsertConnectionLogs(gomock.Any(), arg).Return(nil).AnyTimes()
s.Run("UpsertConnectionLog", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.WorkspaceTable{})
arg := database.UpsertConnectionLogParams{Ip: defaultIPAddress(), Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, ConnectionStatus: database.ConnectionStatusConnected, WorkspaceOwnerID: ws.OwnerID}
dbm.EXPECT().UpsertConnectionLog(gomock.Any(), arg).Return(database.ConnectionLog{}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceConnectionLog, policy.ActionUpdate)
}))
s.Run("GetConnectionLogsOffset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
@@ -400,17 +401,6 @@ func (s *MethodTestSuite) TestChats() {
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("LinkChatFiles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{uuid.New()},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().LinkChatFiles(gomock.Any(), arg).Return(int32(0), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int32(0))
}))
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()
@@ -587,19 +577,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{file.ID}).Return([]database.ChatFile{file}, nil).AnyTimes()
check.Args([]uuid.UUID{file.ID}).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns([]database.ChatFile{file})
}))
s.Run("GetChatFileMetadataByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
file := testutil.Fake(s.T(), faker, database.ChatFile{})
rows := []database.GetChatFileMetadataByChatIDRow{{
ID: file.ID,
Name: file.Name,
Mimetype: file.Mimetype,
CreatedAt: file.CreatedAt,
OwnerID: file.OwnerID,
OrganizationID: file.OrganizationID,
}}
dbm.EXPECT().GetChatFileMetadataByChatID(gomock.Any(), file.ID).Return(rows, nil).AnyTimes()
check.Args(file.ID).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns(rows)
}))
s.Run("GetChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})
@@ -744,9 +721,7 @@ 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{
Status: database.ChatStatusWaiting,
})
arg := testutil.Fake(s.T(), faker, database.InsertChatParams{})
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)
@@ -842,15 +817,15 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatStatusPreserveUpdatedAt(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatHeartbeats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
resultID := uuid.New()
arg := database.UpdateChatHeartbeatsParams{
IDs: []uuid.UUID{resultID},
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{
ID: chat.ID,
WorkerID: uuid.New(),
Now: time.Now(),
}
dbm.EXPECT().UpdateChatHeartbeats(gomock.Any(), arg).Return([]uuid.UUID{resultID}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns([]uuid.UUID{resultID})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatHeartbeat(gomock.Any(), arg).Return(int64(1), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("UpdateChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
@@ -1146,6 +1121,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().CleanupDeletedMCPServerIDsFromChats(gomock.Any()).Return(nil).AnyTimes()
check.Args().Asserts(rbac.ResourceChat, policy.ActionUpdate)
}))
s.Run("CleanupDeletedMCPServerIDsFromChatAutomations", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().CleanupDeletedMCPServerIDsFromChatAutomations(gomock.Any()).Return(nil).AnyTimes()
check.Args().Asserts(rbac.ResourceChatAutomation, policy.ActionUpdate)
}))
s.Run("DeleteMCPServerConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
id := uuid.New()
dbm.EXPECT().DeleteMCPServerConfigByID(gomock.Any(), id).Return(nil).AnyTimes()
@@ -1275,6 +1254,226 @@ func (s *MethodTestSuite) TestChats() {
}))
}
func (s *MethodTestSuite) TestChatAutomations() {
s.Run("CountChatAutomationChatCreatesInWindow", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
arg := database.CountChatAutomationChatCreatesInWindowParams{
AutomationID: automation.ID,
WindowStart: dbtime.Now().Add(-time.Hour),
}
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().CountChatAutomationChatCreatesInWindow(gomock.Any(), arg).Return(int64(3), nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionRead).Returns(int64(3))
}))
s.Run("CountChatAutomationMessagesInWindow", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
arg := database.CountChatAutomationMessagesInWindowParams{
AutomationID: automation.ID,
WindowStart: dbtime.Now().Add(-time.Hour),
}
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().CountChatAutomationMessagesInWindow(gomock.Any(), arg).Return(int64(5), nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionRead).Returns(int64(5))
}))
s.Run("DeleteChatAutomationByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().DeleteChatAutomationByID(gomock.Any(), automation.ID).Return(nil).AnyTimes()
check.Args(automation.ID).Asserts(automation, policy.ActionDelete).Returns()
}))
s.Run("DeleteChatAutomationTriggerByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
trigger := testutil.Fake(s.T(), faker, database.ChatAutomationTrigger{
AutomationID: automation.ID,
Type: database.ChatAutomationTriggerTypeWebhook,
})
dbm.EXPECT().GetChatAutomationTriggerByID(gomock.Any(), trigger.ID).Return(trigger, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().DeleteChatAutomationTriggerByID(gomock.Any(), trigger.ID).Return(nil).AnyTimes()
check.Args(trigger.ID).Asserts(automation, policy.ActionUpdate).Returns()
}))
s.Run("GetActiveChatAutomationCronTriggers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
rows := []database.GetActiveChatAutomationCronTriggersRow{}
dbm.EXPECT().GetActiveChatAutomationCronTriggers(gomock.Any()).Return(rows, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceChatAutomation.All(), policy.ActionRead).Returns(rows)
}))
s.Run("GetChatAutomationByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
check.Args(automation.ID).Asserts(automation, policy.ActionRead).Returns(automation)
}))
s.Run("GetChatAutomationEventsByAutomationID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
arg := database.GetChatAutomationEventsByAutomationIDParams{
AutomationID: automation.ID,
}
events := []database.ChatAutomationEvent{}
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationEventsByAutomationID(gomock.Any(), arg).Return(events, nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionRead).Returns(events)
}))
s.Run("GetChatAutomationTriggerByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
trigger := testutil.Fake(s.T(), faker, database.ChatAutomationTrigger{
AutomationID: automation.ID,
Type: database.ChatAutomationTriggerTypeWebhook,
})
dbm.EXPECT().GetChatAutomationTriggerByID(gomock.Any(), trigger.ID).Return(trigger, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
check.Args(trigger.ID).Asserts(automation, policy.ActionRead).Returns(trigger)
}))
s.Run("GetChatAutomationTriggersByAutomationID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
triggers := []database.ChatAutomationTrigger{}
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationTriggersByAutomationID(gomock.Any(), automation.ID).Return(triggers, nil).AnyTimes()
check.Args(automation.ID).Asserts(automation, policy.ActionRead).Returns(triggers)
}))
s.Run("GetChatAutomations", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatAutomationsParams{}
dbm.EXPECT().GetChatAutomations(gomock.Any(), params).Return([]database.ChatAutomation{}, nil).AnyTimes()
dbm.EXPECT().GetAuthorizedChatAutomations(gomock.Any(), params, gomock.Any()).Return([]database.ChatAutomation{}, nil).AnyTimes()
check.Args(params).Asserts(rbac.ResourceChatAutomation.All(), policy.ActionRead).WithNotAuthorized("nil")
}))
s.Run("GetAuthorizedChatAutomations", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatAutomationsParams{}
dbm.EXPECT().GetAuthorizedChatAutomations(gomock.Any(), params, gomock.Any()).Return([]database.ChatAutomation{}, nil).AnyTimes()
dbm.EXPECT().GetChatAutomations(gomock.Any(), params).Return([]database.ChatAutomation{}, nil).AnyTimes()
check.Args(params, emptyPreparedAuthorized{}).Asserts(rbac.ResourceChatAutomation.All(), policy.ActionRead)
}))
s.Run("InsertChatAutomation", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.InsertChatAutomationParams{
ID: uuid.New(),
OwnerID: uuid.New(),
OrganizationID: uuid.New(),
Name: "test-automation",
Description: "test description",
Instructions: "test instructions",
Status: database.ChatAutomationStatusActive,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
}
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{
ID: arg.ID,
OwnerID: arg.OwnerID,
OrganizationID: arg.OrganizationID,
Status: arg.Status,
})
dbm.EXPECT().InsertChatAutomation(gomock.Any(), arg).Return(automation, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChatAutomation.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionCreate).Returns(automation)
}))
s.Run("InsertChatAutomationEvent", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
arg := database.InsertChatAutomationEventParams{
ID: uuid.New(),
AutomationID: automation.ID,
ReceivedAt: dbtime.Now(),
Payload: json.RawMessage(`{}`),
Status: database.ChatAutomationEventStatusFiltered,
}
event := testutil.Fake(s.T(), faker, database.ChatAutomationEvent{
ID: arg.ID,
AutomationID: automation.ID,
Status: arg.Status,
})
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().InsertChatAutomationEvent(gomock.Any(), arg).Return(event, nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionUpdate).Returns(event)
}))
s.Run("InsertChatAutomationTrigger", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
arg := database.InsertChatAutomationTriggerParams{
ID: uuid.New(),
AutomationID: automation.ID,
Type: database.ChatAutomationTriggerTypeWebhook,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
}
trigger := testutil.Fake(s.T(), faker, database.ChatAutomationTrigger{
ID: arg.ID,
AutomationID: automation.ID,
Type: arg.Type,
})
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().InsertChatAutomationTrigger(gomock.Any(), arg).Return(trigger, nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionUpdate).Returns(trigger)
}))
s.Run("PurgeOldChatAutomationEvents", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.PurgeOldChatAutomationEventsParams{
Before: dbtime.Now().Add(-7 * 24 * time.Hour),
LimitCount: 1000,
}
dbm.EXPECT().PurgeOldChatAutomationEvents(gomock.Any(), arg).Return(int64(5), nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChatAutomation.All(), policy.ActionDelete).Returns(int64(5))
}))
s.Run("UpdateChatAutomation", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
arg := database.UpdateChatAutomationParams{
ID: automation.ID,
Name: "updated-name",
Description: "updated description",
Status: database.ChatAutomationStatusActive,
UpdatedAt: dbtime.Now(),
}
updated := automation
updated.Name = arg.Name
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().UpdateChatAutomation(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionUpdate).Returns(updated)
}))
s.Run("UpdateChatAutomationTrigger", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
trigger := testutil.Fake(s.T(), faker, database.ChatAutomationTrigger{
AutomationID: automation.ID,
Type: database.ChatAutomationTriggerTypeCron,
})
arg := database.UpdateChatAutomationTriggerParams{
ID: trigger.ID,
UpdatedAt: dbtime.Now(),
}
updated := trigger
dbm.EXPECT().GetChatAutomationTriggerByID(gomock.Any(), trigger.ID).Return(trigger, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().UpdateChatAutomationTrigger(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionUpdate).Returns(updated)
}))
s.Run("UpdateChatAutomationTriggerLastTriggeredAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
trigger := testutil.Fake(s.T(), faker, database.ChatAutomationTrigger{
AutomationID: automation.ID,
Type: database.ChatAutomationTriggerTypeCron,
})
arg := database.UpdateChatAutomationTriggerLastTriggeredAtParams{
ID: trigger.ID,
LastTriggeredAt: dbtime.Now(),
}
dbm.EXPECT().GetChatAutomationTriggerByID(gomock.Any(), trigger.ID).Return(trigger, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().UpdateChatAutomationTriggerLastTriggeredAt(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionUpdate).Returns()
}))
s.Run("UpdateChatAutomationTriggerWebhookSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
automation := testutil.Fake(s.T(), faker, database.ChatAutomation{Status: database.ChatAutomationStatusActive})
trigger := testutil.Fake(s.T(), faker, database.ChatAutomationTrigger{
AutomationID: automation.ID,
Type: database.ChatAutomationTriggerTypeWebhook,
})
arg := database.UpdateChatAutomationTriggerWebhookSecretParams{
ID: trigger.ID,
UpdatedAt: dbtime.Now(),
WebhookSecret: sql.NullString{
String: "new-secret",
Valid: true,
},
}
updated := trigger
dbm.EXPECT().GetChatAutomationTriggerByID(gomock.Any(), trigger.ID).Return(trigger, nil).AnyTimes()
dbm.EXPECT().GetChatAutomationByID(gomock.Any(), automation.ID).Return(automation, nil).AnyTimes()
dbm.EXPECT().UpdateChatAutomationTriggerWebhookSecret(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).Asserts(automation, policy.ActionUpdate).Returns(updated)
}))
}
func (s *MethodTestSuite) TestFile() {
s.Run("GetFileByHashAndCreator", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
f := testutil.Fake(s.T(), faker, database.File{})
@@ -2430,36 +2629,6 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().GetUserChatCustomPrompt(gomock.Any(), u.ID).Return("my custom prompt", nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("my custom prompt")
}))
s.Run("GetUserChatProviderKeys", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
key := testutil.Fake(s.T(), faker, database.UserChatProviderKey{UserID: u.ID})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().GetUserChatProviderKeys(gomock.Any(), u.ID).Return([]database.UserChatProviderKey{key}, nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns([]database.UserChatProviderKey{key})
}))
s.Run("DeleteUserChatProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.DeleteUserChatProviderKeyParams{UserID: u.ID, ChatProviderID: uuid.New()}
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().DeleteUserChatProviderKey(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns()
}))
s.Run("UpdateUserChatProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserChatProviderKeyParams{UserID: u.ID, ChatProviderID: uuid.New(), APIKey: "updated-api-key"}
key := testutil.Fake(s.T(), faker, database.UserChatProviderKey{UserID: u.ID, ChatProviderID: arg.ChatProviderID, APIKey: arg.APIKey})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().UpdateUserChatProviderKey(gomock.Any(), arg).Return(key, nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(key)
}))
s.Run("UpsertUserChatProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpsertUserChatProviderKeyParams{UserID: u.ID, ChatProviderID: uuid.New(), APIKey: "upserted-api-key"}
key := testutil.Fake(s.T(), faker, database.UserChatProviderKey{UserID: u.ID, ChatProviderID: arg.ChatProviderID, APIKey: arg.APIKey})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().UpsertUserChatProviderKey(gomock.Any(), arg).Return(key, nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(key)
}))
s.Run("UpdateUserChatCustomPrompt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
uc := database.UserConfig{UserID: u.ID, Key: "chat_custom_prompt", Value: "my custom prompt"}
@@ -3773,11 +3942,13 @@ func (s *MethodTestSuite) TestTailnetFunctions() {
check.Args(uuid.New()).
Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
}))
s.Run("GetTailnetTunnelPeerBindingsBatch", s.Subtest(func(_ database.Store, check *expects) {
check.Args([]uuid.UUID{uuid.New()}).Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
s.Run("GetTailnetTunnelPeerBindings", s.Subtest(func(_ database.Store, check *expects) {
check.Args(uuid.New()).
Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
}))
s.Run("GetTailnetTunnelPeerIDsBatch", s.Subtest(func(_ database.Store, check *expects) {
check.Args([]uuid.UUID{uuid.New()}).Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
s.Run("GetTailnetTunnelPeerIDs", s.Subtest(func(_ database.Store, check *expects) {
check.Args(uuid.New()).
Asserts(rbac.ResourceTailnetCoordinator, policy.ActionRead)
}))
s.Run("GetAllTailnetCoordinators", s.Subtest(func(_ database.Store, check *expects) {
check.Args().
@@ -5346,20 +5517,19 @@ func (s *MethodTestSuite) TestUserSecrets() {
Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionRead).
Returns(secret)
}))
s.Run("GetUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
secret := testutil.Fake(s.T(), faker, database.UserSecret{})
dbm.EXPECT().GetUserSecret(gomock.Any(), secret.ID).Return(secret, nil).AnyTimes()
check.Args(secret.ID).
Asserts(secret, policy.ActionRead).
Returns(secret)
}))
s.Run("ListUserSecrets", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
row := testutil.Fake(s.T(), faker, database.ListUserSecretsRow{UserID: user.ID})
dbm.EXPECT().ListUserSecrets(gomock.Any(), user.ID).Return([]database.ListUserSecretsRow{row}, nil).AnyTimes()
secret := testutil.Fake(s.T(), faker, database.UserSecret{UserID: user.ID})
dbm.EXPECT().ListUserSecrets(gomock.Any(), user.ID).Return([]database.UserSecret{secret}, nil).AnyTimes()
check.Args(user.ID).
Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionRead).
Returns([]database.ListUserSecretsRow{row})
}))
s.Run("ListUserSecretsWithValues", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
secret := testutil.Fake(s.T(), faker, database.UserSecret{UserID: user.ID})
dbm.EXPECT().ListUserSecretsWithValues(gomock.Any(), user.ID).Return([]database.UserSecret{secret}, nil).AnyTimes()
check.Args(user.ID).
Asserts(rbac.ResourceSystem, policy.ActionRead).
Returns([]database.UserSecret{secret})
}))
s.Run("CreateUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
@@ -5371,21 +5541,22 @@ func (s *MethodTestSuite) TestUserSecrets() {
Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionCreate).
Returns(ret)
}))
s.Run("UpdateUserSecretByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
updated := testutil.Fake(s.T(), faker, database.UserSecret{UserID: user.ID})
arg := database.UpdateUserSecretByUserIDAndNameParams{UserID: user.ID, Name: "test"}
dbm.EXPECT().UpdateUserSecretByUserIDAndName(gomock.Any(), arg).Return(updated, nil).AnyTimes()
s.Run("UpdateUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
secret := testutil.Fake(s.T(), faker, database.UserSecret{})
updated := testutil.Fake(s.T(), faker, database.UserSecret{ID: secret.ID})
arg := database.UpdateUserSecretParams{ID: secret.ID}
dbm.EXPECT().GetUserSecret(gomock.Any(), secret.ID).Return(secret, nil).AnyTimes()
dbm.EXPECT().UpdateUserSecret(gomock.Any(), arg).Return(updated, nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionUpdate).
Asserts(secret, policy.ActionUpdate).
Returns(updated)
}))
s.Run("DeleteUserSecretByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
user := testutil.Fake(s.T(), faker, database.User{})
arg := database.DeleteUserSecretByUserIDAndNameParams{UserID: user.ID, Name: "test"}
dbm.EXPECT().DeleteUserSecretByUserIDAndName(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).
Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionDelete).
s.Run("DeleteUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
secret := testutil.Fake(s.T(), faker, database.UserSecret{})
dbm.EXPECT().GetUserSecret(gomock.Any(), secret.ID).Return(secret, nil).AnyTimes()
dbm.EXPECT().DeleteUserSecret(gomock.Any(), secret.ID).Return(nil).AnyTimes()
check.Args(secret.ID).
Asserts(secret, policy.ActionRead, secret, policy.ActionDelete).
Returns()
}))
}
+10 -58
View File
@@ -76,7 +76,7 @@ func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database.
}
func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnectionLogParams) database.ConnectionLog {
arg := database.UpsertConnectionLogParams{
log, err := db.UpsertConnectionLog(genCtx, database.UpsertConnectionLogParams{
ID: takeFirst(seed.ID, uuid.New()),
Time: takeFirst(seed.Time, dbtime.Now()),
OrganizationID: takeFirst(seed.OrganizationID, uuid.New()),
@@ -89,7 +89,7 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnecti
Int32: takeFirst(seed.Code.Int32, 0),
Valid: takeFirst(seed.Code.Valid, false),
},
IP: pqtype.Inet{
Ip: pqtype.Inet{
IPNet: net.IPNet{
IP: net.IPv4(127, 0, 0, 1),
Mask: net.IPv4Mask(255, 255, 255, 255),
@@ -117,53 +117,9 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnecti
Valid: takeFirst(seed.DisconnectReason.Valid, false),
},
ConnectionStatus: takeFirst(seed.ConnectionStatus, database.ConnectionStatusConnected),
}
var disconnectTime sql.NullTime
if arg.ConnectionStatus == database.ConnectionStatusDisconnected {
disconnectTime = sql.NullTime{Time: arg.Time, Valid: true}
}
err := db.BatchUpsertConnectionLogs(genCtx, database.BatchUpsertConnectionLogsParams{
ID: []uuid.UUID{arg.ID},
ConnectTime: []time.Time{arg.Time},
OrganizationID: []uuid.UUID{arg.OrganizationID},
WorkspaceOwnerID: []uuid.UUID{arg.WorkspaceOwnerID},
WorkspaceID: []uuid.UUID{arg.WorkspaceID},
WorkspaceName: []string{arg.WorkspaceName},
AgentName: []string{arg.AgentName},
Type: []database.ConnectionType{arg.Type},
Code: []int32{arg.Code.Int32},
CodeValid: []bool{arg.Code.Valid},
Ip: []pqtype.Inet{arg.IP},
UserAgent: []string{arg.UserAgent.String},
UserID: []uuid.UUID{arg.UserID.UUID},
SlugOrPort: []string{arg.SlugOrPort.String},
ConnectionID: []uuid.UUID{arg.ConnectionID.UUID},
DisconnectReason: []string{arg.DisconnectReason.String},
DisconnectTime: []time.Time{disconnectTime.Time},
})
require.NoError(t, err, "insert connection log")
// Query back the actual row from the database. On upsert
// conflict the DB keeps the original row's ID, so we can't
// rely on arg.ID. Match on the conflict key for rows with a
// connection_id, or by primary key for NULL connection_id.
rows, err := db.GetConnectionLogsOffset(genCtx, database.GetConnectionLogsOffsetParams{})
require.NoError(t, err, "query connection logs")
for _, row := range rows {
if arg.ConnectionID.Valid {
if row.ConnectionLog.ConnectionID == arg.ConnectionID &&
row.ConnectionLog.WorkspaceID == arg.WorkspaceID &&
row.ConnectionLog.AgentName == arg.AgentName {
return row.ConnectionLog
}
} else if row.ConnectionLog.ID == arg.ID {
return row.ConnectionLog
}
}
require.Failf(t, "connection log not found", "id=%s", arg.ID)
return database.ConnectionLog{} // unreachable
return log
}
func Template(t testing.TB, db database.Store, seed database.Template) database.Template {
@@ -1597,7 +1553,6 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret) datab
Name: takeFirst(seed.Name, "secret-name"),
Description: takeFirst(seed.Description, "secret description"),
Value: takeFirst(seed.Value, "secret value"),
ValueKeyID: seed.ValueKeyID,
EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"),
FilePath: takeFirst(seed.FilePath, "~/secret/file/path"),
})
@@ -1636,7 +1591,6 @@ 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()),
@@ -1658,15 +1612,13 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
func AIBridgeTokenUsage(t testing.TB, db database.Store, seed database.InsertAIBridgeTokenUsageParams) database.AIBridgeTokenUsage {
usage, err := db.InsertAIBridgeTokenUsage(genCtx, database.InsertAIBridgeTokenUsageParams{
ID: takeFirst(seed.ID, uuid.New()),
InterceptionID: takeFirst(seed.InterceptionID, uuid.New()),
ProviderResponseID: takeFirst(seed.ProviderResponseID, "provider_response_id"),
InputTokens: takeFirst(seed.InputTokens, 100),
OutputTokens: takeFirst(seed.OutputTokens, 100),
CacheReadInputTokens: seed.CacheReadInputTokens,
CacheWriteInputTokens: seed.CacheWriteInputTokens,
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
ID: takeFirst(seed.ID, uuid.New()),
InterceptionID: takeFirst(seed.InterceptionID, uuid.New()),
ProviderResponseID: takeFirst(seed.ProviderResponseID, "provider_response_id"),
InputTokens: takeFirst(seed.InputTokens, 100),
OutputTokens: takeFirst(seed.OutputTokens, 100),
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
})
require.NoError(t, err, "insert aibridge token usage")
return usage
+197 -85
View File
@@ -208,14 +208,6 @@ func (m queryMetricsStore) BatchUpdateWorkspaceNextStartAt(ctx context.Context,
return r0
}
func (m queryMetricsStore) BatchUpsertConnectionLogs(ctx context.Context, arg database.BatchUpsertConnectionLogsParams) error {
start := time.Now()
r0 := m.s.BatchUpsertConnectionLogs(ctx, arg)
m.queryLatencies.WithLabelValues("BatchUpsertConnectionLogs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "BatchUpsertConnectionLogs").Inc()
return r0
}
func (m queryMetricsStore) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.BulkMarkNotificationMessagesFailed(ctx, arg)
@@ -272,6 +264,14 @@ func (m queryMetricsStore) CleanTailnetTunnels(ctx context.Context) error {
return r0
}
func (m queryMetricsStore) CleanupDeletedMCPServerIDsFromChatAutomations(ctx context.Context) error {
start := time.Now()
r0 := m.s.CleanupDeletedMCPServerIDsFromChatAutomations(ctx)
m.queryLatencies.WithLabelValues("CleanupDeletedMCPServerIDsFromChatAutomations").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CleanupDeletedMCPServerIDsFromChatAutomations").Inc()
return r0
}
func (m queryMetricsStore) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error {
start := time.Now()
r0 := m.s.CleanupDeletedMCPServerIDsFromChats(ctx)
@@ -304,6 +304,22 @@ func (m queryMetricsStore) CountAuditLogs(ctx context.Context, arg database.Coun
return r0, r1
}
func (m queryMetricsStore) CountChatAutomationChatCreatesInWindow(ctx context.Context, arg database.CountChatAutomationChatCreatesInWindowParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountChatAutomationChatCreatesInWindow(ctx, arg)
m.queryLatencies.WithLabelValues("CountChatAutomationChatCreatesInWindow").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountChatAutomationChatCreatesInWindow").Inc()
return r0, r1
}
func (m queryMetricsStore) CountChatAutomationMessagesInWindow(ctx context.Context, arg database.CountChatAutomationMessagesInWindowParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountChatAutomationMessagesInWindow(ctx, arg)
m.queryLatencies.WithLabelValues("CountChatAutomationMessagesInWindow").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountChatAutomationMessagesInWindow").Inc()
return r0, r1
}
func (m queryMetricsStore) CountConnectionLogs(ctx context.Context, arg database.CountConnectionLogsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountConnectionLogs(ctx, arg)
@@ -408,6 +424,22 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C
return r0
}
func (m queryMetricsStore) DeleteChatAutomationByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteChatAutomationByID(ctx, id)
m.queryLatencies.WithLabelValues("DeleteChatAutomationByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatAutomationByID").Inc()
return r0
}
func (m queryMetricsStore) DeleteChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteChatAutomationTriggerByID(ctx, id)
m.queryLatencies.WithLabelValues("DeleteChatAutomationTriggerByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatAutomationTriggerByID").Inc()
return r0
}
func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteChatModelConfigByID(ctx, id)
@@ -704,19 +736,11 @@ func (m queryMetricsStore) DeleteUserChatCompactionThreshold(ctx context.Context
return r0
}
func (m queryMetricsStore) DeleteUserChatProviderKey(ctx context.Context, arg database.DeleteUserChatProviderKeyParams) error {
func (m queryMetricsStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteUserChatProviderKey(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteUserChatProviderKey").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserChatProviderKey").Inc()
return r0
}
func (m queryMetricsStore) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) error {
start := time.Now()
r0 := m.s.DeleteUserSecretByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteUserSecretByUserIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserSecretByUserIDAndName").Inc()
r0 := m.s.DeleteUserSecret(ctx, id)
m.queryLatencies.WithLabelValues("DeleteUserSecret").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserSecret").Inc()
return r0
}
@@ -952,6 +976,14 @@ func (m queryMetricsStore) GetActiveAISeatCount(ctx context.Context) (int64, err
return r0, r1
}
func (m queryMetricsStore) GetActiveChatAutomationCronTriggers(ctx context.Context) ([]database.GetActiveChatAutomationCronTriggersRow, error) {
start := time.Now()
r0, r1 := m.s.GetActiveChatAutomationCronTriggers(ctx)
m.queryLatencies.WithLabelValues("GetActiveChatAutomationCronTriggers").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetActiveChatAutomationCronTriggers").Inc()
return r0, r1
}
func (m queryMetricsStore) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
start := time.Now()
r0, r1 := m.s.GetActivePresetPrebuildSchedules(ctx)
@@ -1048,6 +1080,46 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID
return r0, r1
}
func (m queryMetricsStore) GetChatAutomationByID(ctx context.Context, id uuid.UUID) (database.ChatAutomation, error) {
start := time.Now()
r0, r1 := m.s.GetChatAutomationByID(ctx, id)
m.queryLatencies.WithLabelValues("GetChatAutomationByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatAutomationByID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatAutomationEventsByAutomationID(ctx context.Context, arg database.GetChatAutomationEventsByAutomationIDParams) ([]database.ChatAutomationEvent, error) {
start := time.Now()
r0, r1 := m.s.GetChatAutomationEventsByAutomationID(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatAutomationEventsByAutomationID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatAutomationEventsByAutomationID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) (database.ChatAutomationTrigger, error) {
start := time.Now()
r0, r1 := m.s.GetChatAutomationTriggerByID(ctx, id)
m.queryLatencies.WithLabelValues("GetChatAutomationTriggerByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatAutomationTriggerByID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatAutomationTriggersByAutomationID(ctx context.Context, automationID uuid.UUID) ([]database.ChatAutomationTrigger, error) {
start := time.Now()
r0, r1 := m.s.GetChatAutomationTriggersByAutomationID(ctx, automationID)
m.queryLatencies.WithLabelValues("GetChatAutomationTriggersByAutomationID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatAutomationTriggersByAutomationID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatAutomations(ctx context.Context, arg database.GetChatAutomationsParams) ([]database.ChatAutomation, error) {
start := time.Now()
r0, r1 := m.s.GetChatAutomations(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatAutomations").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatAutomations").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetChatByID(ctx, id)
@@ -1128,14 +1200,6 @@ func (m queryMetricsStore) GetChatFileByID(ctx context.Context, id uuid.UUID) (d
return r0, r1
}
func (m queryMetricsStore) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatFileMetadataByChatID(ctx, chatID)
m.queryLatencies.WithLabelValues("GetChatFileMetadataByChatID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatFileMetadataByChatID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
start := time.Now()
r0, r1 := m.s.GetChatFilesByIDs(ctx, ids)
@@ -2232,19 +2296,19 @@ func (m queryMetricsStore) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([
return r0, r1
}
func (m queryMetricsStore) GetTailnetTunnelPeerBindingsBatch(ctx context.Context, ids []uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsBatchRow, error) {
func (m queryMetricsStore) GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsRow, error) {
start := time.Now()
r0, r1 := m.s.GetTailnetTunnelPeerBindingsBatch(ctx, ids)
m.queryLatencies.WithLabelValues("GetTailnetTunnelPeerBindingsBatch").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTailnetTunnelPeerBindingsBatch").Inc()
r0, r1 := m.s.GetTailnetTunnelPeerBindings(ctx, srcID)
m.queryLatencies.WithLabelValues("GetTailnetTunnelPeerBindings").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTailnetTunnelPeerBindings").Inc()
return r0, r1
}
func (m queryMetricsStore) GetTailnetTunnelPeerIDsBatch(ctx context.Context, ids []uuid.UUID) ([]database.GetTailnetTunnelPeerIDsBatchRow, error) {
func (m queryMetricsStore) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerIDsRow, error) {
start := time.Now()
r0, r1 := m.s.GetTailnetTunnelPeerIDsBatch(ctx, ids)
m.queryLatencies.WithLabelValues("GetTailnetTunnelPeerIDsBatch").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTailnetTunnelPeerIDsBatch").Inc()
r0, r1 := m.s.GetTailnetTunnelPeerIDs(ctx, srcID)
m.queryLatencies.WithLabelValues("GetTailnetTunnelPeerIDs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTailnetTunnelPeerIDs").Inc()
return r0, r1
}
@@ -2552,14 +2616,6 @@ func (m queryMetricsStore) GetUserChatCustomPrompt(ctx context.Context, userID u
return r0, r1
}
func (m queryMetricsStore) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]database.UserChatProviderKey, error) {
start := time.Now()
r0, r1 := m.s.GetUserChatProviderKeys(ctx, userID)
m.queryLatencies.WithLabelValues("GetUserChatProviderKeys").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserChatProviderKeys").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetUserChatSpendInPeriod(ctx, arg)
@@ -2624,6 +2680,14 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u
return r0, r1
}
func (m queryMetricsStore) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) {
start := time.Now()
r0, r1 := m.s.GetUserSecret(ctx, id)
m.queryLatencies.WithLabelValues("GetUserSecret").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserSecret").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) {
start := time.Now()
r0, r1 := m.s.GetUserSecretByUserIDAndName(ctx, arg)
@@ -3248,6 +3312,30 @@ func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertCh
return r0, r1
}
func (m queryMetricsStore) InsertChatAutomation(ctx context.Context, arg database.InsertChatAutomationParams) (database.ChatAutomation, error) {
start := time.Now()
r0, r1 := m.s.InsertChatAutomation(ctx, arg)
m.queryLatencies.WithLabelValues("InsertChatAutomation").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatAutomation").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertChatAutomationEvent(ctx context.Context, arg database.InsertChatAutomationEventParams) (database.ChatAutomationEvent, error) {
start := time.Now()
r0, r1 := m.s.InsertChatAutomationEvent(ctx, arg)
m.queryLatencies.WithLabelValues("InsertChatAutomationEvent").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatAutomationEvent").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertChatAutomationTrigger(ctx context.Context, arg database.InsertChatAutomationTriggerParams) (database.ChatAutomationTrigger, error) {
start := time.Now()
r0, r1 := m.s.InsertChatAutomationTrigger(ctx, arg)
m.queryLatencies.WithLabelValues("InsertChatAutomationTrigger").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatAutomationTrigger").Inc()
return r0, r1
}
func (m queryMetricsStore) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
start := time.Now()
r0, r1 := m.s.InsertChatFile(ctx, arg)
@@ -3776,14 +3864,6 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
start := time.Now()
r0, r1 := m.s.LinkChatFiles(ctx, arg)
m.queryLatencies.WithLabelValues("LinkChatFiles").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "LinkChatFiles").Inc()
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)
@@ -3912,7 +3992,7 @@ func (m queryMetricsStore) ListUserChatCompactionThresholds(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) {
func (m queryMetricsStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) {
start := time.Now()
r0, r1 := m.s.ListUserSecrets(ctx, userID)
m.queryLatencies.WithLabelValues("ListUserSecrets").Observe(time.Since(start).Seconds())
@@ -3920,14 +4000,6 @@ func (m queryMetricsStore) ListUserSecrets(ctx context.Context, userID uuid.UUID
return r0, r1
}
func (m queryMetricsStore) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) {
start := time.Now()
r0, r1 := m.s.ListUserSecretsWithValues(ctx, userID)
m.queryLatencies.WithLabelValues("ListUserSecretsWithValues").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListUserSecretsWithValues").Inc()
return r0, r1
}
func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
start := time.Now()
r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID)
@@ -3992,6 +4064,14 @@ func (m queryMetricsStore) PopNextQueuedMessage(ctx context.Context, chatID uuid
return r0, r1
}
func (m queryMetricsStore) PurgeOldChatAutomationEvents(ctx context.Context, arg database.PurgeOldChatAutomationEventsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.PurgeOldChatAutomationEvents(ctx, arg)
m.queryLatencies.WithLabelValues("PurgeOldChatAutomationEvents").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "PurgeOldChatAutomationEvents").Inc()
return r0, r1
}
func (m queryMetricsStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
start := time.Now()
r0 := m.s.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, templateID)
@@ -4120,6 +4200,38 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up
return r0
}
func (m queryMetricsStore) UpdateChatAutomation(ctx context.Context, arg database.UpdateChatAutomationParams) (database.ChatAutomation, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatAutomation(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatAutomation").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatAutomation").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatAutomationTrigger(ctx context.Context, arg database.UpdateChatAutomationTriggerParams) (database.ChatAutomationTrigger, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatAutomationTrigger(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatAutomationTrigger").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatAutomationTrigger").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatAutomationTriggerLastTriggeredAt(ctx context.Context, arg database.UpdateChatAutomationTriggerLastTriggeredAtParams) error {
start := time.Now()
r0 := m.s.UpdateChatAutomationTriggerLastTriggeredAt(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatAutomationTriggerLastTriggeredAt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatAutomationTriggerLastTriggeredAt").Inc()
return r0
}
func (m queryMetricsStore) UpdateChatAutomationTriggerWebhookSecret(ctx context.Context, arg database.UpdateChatAutomationTriggerWebhookSecretParams) (database.ChatAutomationTrigger, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatAutomationTriggerWebhookSecret(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatAutomationTriggerWebhookSecret").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatAutomationTriggerWebhookSecret").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatBuildAgentBinding(ctx, arg)
@@ -4136,11 +4248,11 @@ func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.Upda
return r0, r1
}
func (m queryMetricsStore) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
func (m queryMetricsStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatHeartbeats(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatHeartbeats").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatHeartbeats").Inc()
r0, r1 := m.s.UpdateChatHeartbeat(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatHeartbeat").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatHeartbeat").Inc()
return r0, r1
}
@@ -4600,14 +4712,6 @@ func (m queryMetricsStore) UpdateUserChatCustomPrompt(ctx context.Context, arg d
return r0, r1
}
func (m queryMetricsStore) UpdateUserChatProviderKey(ctx context.Context, arg database.UpdateUserChatProviderKeyParams) (database.UserChatProviderKey, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserChatProviderKey(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserChatProviderKey").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserChatProviderKey").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.UpdateUserDeletedByID(ctx, id)
@@ -4696,11 +4800,11 @@ func (m queryMetricsStore) UpdateUserRoles(ctx context.Context, arg database.Upd
return r0, r1
}
func (m queryMetricsStore) UpdateUserSecretByUserIDAndName(ctx context.Context, arg database.UpdateUserSecretByUserIDAndNameParams) (database.UserSecret, error) {
func (m queryMetricsStore) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserSecretByUserIDAndName(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserSecretByUserIDAndName").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserSecretByUserIDAndName").Inc()
r0, r1 := m.s.UpdateUserSecret(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserSecret").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserSecret").Inc()
return r0, r1
}
@@ -5048,6 +5152,14 @@ func (m queryMetricsStore) UpsertChatWorkspaceTTL(ctx context.Context, workspace
return r0
}
func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
start := time.Now()
r0, r1 := m.s.UpsertConnectionLog(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertConnectionLog").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertConnectionLog").Inc()
return r0, r1
}
func (m queryMetricsStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error {
start := time.Now()
r0 := m.s.UpsertDefaultProxy(ctx, arg)
@@ -5192,14 +5304,6 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
return r0
}
func (m queryMetricsStore) UpsertUserChatProviderKey(ctx context.Context, arg database.UpsertUserChatProviderKeyParams) (database.UserChatProviderKey, error) {
start := time.Now()
r0, r1 := m.s.UpsertUserChatProviderKey(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertUserChatProviderKey").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserChatProviderKey").Inc()
return r0, r1
}
func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
start := time.Now()
r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg)
@@ -5399,3 +5503,11 @@ func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChats").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAuthorizedChatAutomations(ctx context.Context, arg database.GetChatAutomationsParams, prepared rbac.PreparedAuthorized) ([]database.ChatAutomation, error) {
start := time.Now()
r0, r1 := m.s.GetAuthorizedChatAutomations(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("GetAuthorizedChatAutomations").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChatAutomations").Inc()
return r0, r1
}
+361 -153
View File
@@ -233,20 +233,6 @@ func (mr *MockStoreMockRecorder) BatchUpdateWorkspaceNextStartAt(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchUpdateWorkspaceNextStartAt", reflect.TypeOf((*MockStore)(nil).BatchUpdateWorkspaceNextStartAt), ctx, arg)
}
// BatchUpsertConnectionLogs mocks base method.
func (m *MockStore) BatchUpsertConnectionLogs(ctx context.Context, arg database.BatchUpsertConnectionLogsParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BatchUpsertConnectionLogs", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// BatchUpsertConnectionLogs indicates an expected call of BatchUpsertConnectionLogs.
func (mr *MockStoreMockRecorder) BatchUpsertConnectionLogs(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchUpsertConnectionLogs", reflect.TypeOf((*MockStore)(nil).BatchUpsertConnectionLogs), ctx, arg)
}
// BulkMarkNotificationMessagesFailed mocks base method.
func (m *MockStore) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) {
m.ctrl.T.Helper()
@@ -349,6 +335,20 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), ctx)
}
// CleanupDeletedMCPServerIDsFromChatAutomations mocks base method.
func (m *MockStore) CleanupDeletedMCPServerIDsFromChatAutomations(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CleanupDeletedMCPServerIDsFromChatAutomations", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// CleanupDeletedMCPServerIDsFromChatAutomations indicates an expected call of CleanupDeletedMCPServerIDsFromChatAutomations.
func (mr *MockStoreMockRecorder) CleanupDeletedMCPServerIDsFromChatAutomations(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupDeletedMCPServerIDsFromChatAutomations", reflect.TypeOf((*MockStore)(nil).CleanupDeletedMCPServerIDsFromChatAutomations), ctx)
}
// CleanupDeletedMCPServerIDsFromChats mocks base method.
func (m *MockStore) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error {
m.ctrl.T.Helper()
@@ -468,6 +468,36 @@ func (mr *MockStoreMockRecorder) CountAuthorizedConnectionLogs(ctx, arg, prepare
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAuthorizedConnectionLogs", reflect.TypeOf((*MockStore)(nil).CountAuthorizedConnectionLogs), ctx, arg, prepared)
}
// CountChatAutomationChatCreatesInWindow mocks base method.
func (m *MockStore) CountChatAutomationChatCreatesInWindow(ctx context.Context, arg database.CountChatAutomationChatCreatesInWindowParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountChatAutomationChatCreatesInWindow", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountChatAutomationChatCreatesInWindow indicates an expected call of CountChatAutomationChatCreatesInWindow.
func (mr *MockStoreMockRecorder) CountChatAutomationChatCreatesInWindow(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountChatAutomationChatCreatesInWindow", reflect.TypeOf((*MockStore)(nil).CountChatAutomationChatCreatesInWindow), ctx, arg)
}
// CountChatAutomationMessagesInWindow mocks base method.
func (m *MockStore) CountChatAutomationMessagesInWindow(ctx context.Context, arg database.CountChatAutomationMessagesInWindowParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountChatAutomationMessagesInWindow", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountChatAutomationMessagesInWindow indicates an expected call of CountChatAutomationMessagesInWindow.
func (mr *MockStoreMockRecorder) CountChatAutomationMessagesInWindow(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountChatAutomationMessagesInWindow", reflect.TypeOf((*MockStore)(nil).CountChatAutomationMessagesInWindow), ctx, arg)
}
// CountConnectionLogs mocks base method.
func (m *MockStore) CountConnectionLogs(ctx context.Context, arg database.CountConnectionLogsParams) (int64, error) {
m.ctrl.T.Helper()
@@ -657,6 +687,34 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID)
}
// DeleteChatAutomationByID mocks base method.
func (m *MockStore) DeleteChatAutomationByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatAutomationByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteChatAutomationByID indicates an expected call of DeleteChatAutomationByID.
func (mr *MockStoreMockRecorder) DeleteChatAutomationByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatAutomationByID", reflect.TypeOf((*MockStore)(nil).DeleteChatAutomationByID), ctx, id)
}
// DeleteChatAutomationTriggerByID mocks base method.
func (m *MockStore) DeleteChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatAutomationTriggerByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteChatAutomationTriggerByID indicates an expected call of DeleteChatAutomationTriggerByID.
func (mr *MockStoreMockRecorder) DeleteChatAutomationTriggerByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatAutomationTriggerByID", reflect.TypeOf((*MockStore)(nil).DeleteChatAutomationTriggerByID), ctx, id)
}
// DeleteChatModelConfigByID mocks base method.
func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
@@ -1185,32 +1243,18 @@ func (mr *MockStoreMockRecorder) DeleteUserChatCompactionThreshold(ctx, arg any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserChatCompactionThreshold", reflect.TypeOf((*MockStore)(nil).DeleteUserChatCompactionThreshold), ctx, arg)
}
// DeleteUserChatProviderKey mocks base method.
func (m *MockStore) DeleteUserChatProviderKey(ctx context.Context, arg database.DeleteUserChatProviderKeyParams) error {
// DeleteUserSecret mocks base method.
func (m *MockStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteUserChatProviderKey", ctx, arg)
ret := m.ctrl.Call(m, "DeleteUserSecret", ctx, id)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteUserChatProviderKey indicates an expected call of DeleteUserChatProviderKey.
func (mr *MockStoreMockRecorder) DeleteUserChatProviderKey(ctx, arg any) *gomock.Call {
// DeleteUserSecret indicates an expected call of DeleteUserSecret.
func (mr *MockStoreMockRecorder) DeleteUserSecret(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserChatProviderKey", reflect.TypeOf((*MockStore)(nil).DeleteUserChatProviderKey), ctx, arg)
}
// DeleteUserSecretByUserIDAndName mocks base method.
func (m *MockStore) DeleteUserSecretByUserIDAndName(ctx context.Context, arg database.DeleteUserSecretByUserIDAndNameParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteUserSecretByUserIDAndName", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteUserSecretByUserIDAndName indicates an expected call of DeleteUserSecretByUserIDAndName.
func (mr *MockStoreMockRecorder) DeleteUserSecretByUserIDAndName(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).DeleteUserSecretByUserIDAndName), ctx, arg)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecret", reflect.TypeOf((*MockStore)(nil).DeleteUserSecret), ctx, id)
}
// DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method.
@@ -1637,6 +1681,21 @@ func (mr *MockStoreMockRecorder) GetActiveAISeatCount(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveAISeatCount", reflect.TypeOf((*MockStore)(nil).GetActiveAISeatCount), ctx)
}
// GetActiveChatAutomationCronTriggers mocks base method.
func (m *MockStore) GetActiveChatAutomationCronTriggers(ctx context.Context) ([]database.GetActiveChatAutomationCronTriggersRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveChatAutomationCronTriggers", ctx)
ret0, _ := ret[0].([]database.GetActiveChatAutomationCronTriggersRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetActiveChatAutomationCronTriggers indicates an expected call of GetActiveChatAutomationCronTriggers.
func (mr *MockStoreMockRecorder) GetActiveChatAutomationCronTriggers(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveChatAutomationCronTriggers", reflect.TypeOf((*MockStore)(nil).GetActiveChatAutomationCronTriggers), ctx)
}
// GetActivePresetPrebuildSchedules mocks base method.
func (m *MockStore) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
m.ctrl.T.Helper()
@@ -1832,6 +1891,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedAuditLogsOffset", reflect.TypeOf((*MockStore)(nil).GetAuthorizedAuditLogsOffset), ctx, arg, prepared)
}
// GetAuthorizedChatAutomations mocks base method.
func (m *MockStore) GetAuthorizedChatAutomations(ctx context.Context, arg database.GetChatAutomationsParams, prepared rbac.PreparedAuthorized) ([]database.ChatAutomation, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthorizedChatAutomations", ctx, arg, prepared)
ret0, _ := ret[0].([]database.ChatAutomation)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAuthorizedChatAutomations indicates an expected call of GetAuthorizedChatAutomations.
func (mr *MockStoreMockRecorder) GetAuthorizedChatAutomations(ctx, arg, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatAutomations", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatAutomations), ctx, arg, prepared)
}
// GetAuthorizedChats mocks base method.
func (m *MockStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) {
m.ctrl.T.Helper()
@@ -1922,6 +1996,81 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared)
}
// GetChatAutomationByID mocks base method.
func (m *MockStore) GetChatAutomationByID(ctx context.Context, id uuid.UUID) (database.ChatAutomation, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatAutomationByID", ctx, id)
ret0, _ := ret[0].(database.ChatAutomation)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatAutomationByID indicates an expected call of GetChatAutomationByID.
func (mr *MockStoreMockRecorder) GetChatAutomationByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatAutomationByID", reflect.TypeOf((*MockStore)(nil).GetChatAutomationByID), ctx, id)
}
// GetChatAutomationEventsByAutomationID mocks base method.
func (m *MockStore) GetChatAutomationEventsByAutomationID(ctx context.Context, arg database.GetChatAutomationEventsByAutomationIDParams) ([]database.ChatAutomationEvent, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatAutomationEventsByAutomationID", ctx, arg)
ret0, _ := ret[0].([]database.ChatAutomationEvent)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatAutomationEventsByAutomationID indicates an expected call of GetChatAutomationEventsByAutomationID.
func (mr *MockStoreMockRecorder) GetChatAutomationEventsByAutomationID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatAutomationEventsByAutomationID", reflect.TypeOf((*MockStore)(nil).GetChatAutomationEventsByAutomationID), ctx, arg)
}
// GetChatAutomationTriggerByID mocks base method.
func (m *MockStore) GetChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) (database.ChatAutomationTrigger, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatAutomationTriggerByID", ctx, id)
ret0, _ := ret[0].(database.ChatAutomationTrigger)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatAutomationTriggerByID indicates an expected call of GetChatAutomationTriggerByID.
func (mr *MockStoreMockRecorder) GetChatAutomationTriggerByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatAutomationTriggerByID", reflect.TypeOf((*MockStore)(nil).GetChatAutomationTriggerByID), ctx, id)
}
// GetChatAutomationTriggersByAutomationID mocks base method.
func (m *MockStore) GetChatAutomationTriggersByAutomationID(ctx context.Context, automationID uuid.UUID) ([]database.ChatAutomationTrigger, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatAutomationTriggersByAutomationID", ctx, automationID)
ret0, _ := ret[0].([]database.ChatAutomationTrigger)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatAutomationTriggersByAutomationID indicates an expected call of GetChatAutomationTriggersByAutomationID.
func (mr *MockStoreMockRecorder) GetChatAutomationTriggersByAutomationID(ctx, automationID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatAutomationTriggersByAutomationID", reflect.TypeOf((*MockStore)(nil).GetChatAutomationTriggersByAutomationID), ctx, automationID)
}
// GetChatAutomations mocks base method.
func (m *MockStore) GetChatAutomations(ctx context.Context, arg database.GetChatAutomationsParams) ([]database.ChatAutomation, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatAutomations", ctx, arg)
ret0, _ := ret[0].([]database.ChatAutomation)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatAutomations indicates an expected call of GetChatAutomations.
func (mr *MockStoreMockRecorder) GetChatAutomations(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatAutomations", reflect.TypeOf((*MockStore)(nil).GetChatAutomations), ctx, arg)
}
// GetChatByID mocks base method.
func (m *MockStore) GetChatByID(ctx context.Context, id uuid.UUID) (database.Chat, error) {
m.ctrl.T.Helper()
@@ -2072,21 +2221,6 @@ func (mr *MockStoreMockRecorder) GetChatFileByID(ctx, id any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFileByID", reflect.TypeOf((*MockStore)(nil).GetChatFileByID), ctx, id)
}
// GetChatFileMetadataByChatID mocks base method.
func (m *MockStore) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatFileMetadataByChatID", ctx, chatID)
ret0, _ := ret[0].([]database.GetChatFileMetadataByChatIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatFileMetadataByChatID indicates an expected call of GetChatFileMetadataByChatID.
func (mr *MockStoreMockRecorder) GetChatFileMetadataByChatID(ctx, chatID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFileMetadataByChatID", reflect.TypeOf((*MockStore)(nil).GetChatFileMetadataByChatID), ctx, chatID)
}
// GetChatFilesByIDs mocks base method.
func (m *MockStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
m.ctrl.T.Helper()
@@ -4142,34 +4276,34 @@ func (mr *MockStoreMockRecorder) GetTailnetPeers(ctx, id any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetPeers", reflect.TypeOf((*MockStore)(nil).GetTailnetPeers), ctx, id)
}
// GetTailnetTunnelPeerBindingsBatch mocks base method.
func (m *MockStore) GetTailnetTunnelPeerBindingsBatch(ctx context.Context, ids []uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsBatchRow, error) {
// GetTailnetTunnelPeerBindings mocks base method.
func (m *MockStore) GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerBindingsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTailnetTunnelPeerBindingsBatch", ctx, ids)
ret0, _ := ret[0].([]database.GetTailnetTunnelPeerBindingsBatchRow)
ret := m.ctrl.Call(m, "GetTailnetTunnelPeerBindings", ctx, srcID)
ret0, _ := ret[0].([]database.GetTailnetTunnelPeerBindingsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTailnetTunnelPeerBindingsBatch indicates an expected call of GetTailnetTunnelPeerBindingsBatch.
func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerBindingsBatch(ctx, ids any) *gomock.Call {
// GetTailnetTunnelPeerBindings indicates an expected call of GetTailnetTunnelPeerBindings.
func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerBindings(ctx, srcID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerBindingsBatch", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerBindingsBatch), ctx, ids)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerBindings", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerBindings), ctx, srcID)
}
// GetTailnetTunnelPeerIDsBatch mocks base method.
func (m *MockStore) GetTailnetTunnelPeerIDsBatch(ctx context.Context, ids []uuid.UUID) ([]database.GetTailnetTunnelPeerIDsBatchRow, error) {
// GetTailnetTunnelPeerIDs mocks base method.
func (m *MockStore) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]database.GetTailnetTunnelPeerIDsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTailnetTunnelPeerIDsBatch", ctx, ids)
ret0, _ := ret[0].([]database.GetTailnetTunnelPeerIDsBatchRow)
ret := m.ctrl.Call(m, "GetTailnetTunnelPeerIDs", ctx, srcID)
ret0, _ := ret[0].([]database.GetTailnetTunnelPeerIDsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTailnetTunnelPeerIDsBatch indicates an expected call of GetTailnetTunnelPeerIDsBatch.
func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerIDsBatch(ctx, ids any) *gomock.Call {
// GetTailnetTunnelPeerIDs indicates an expected call of GetTailnetTunnelPeerIDs.
func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerIDs(ctx, srcID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerIDsBatch", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerIDsBatch), ctx, ids)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerIDs", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerIDs), ctx, srcID)
}
// GetTaskByID mocks base method.
@@ -4772,21 +4906,6 @@ func (mr *MockStoreMockRecorder) GetUserChatCustomPrompt(ctx, userID any) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatCustomPrompt", reflect.TypeOf((*MockStore)(nil).GetUserChatCustomPrompt), ctx, userID)
}
// GetUserChatProviderKeys mocks base method.
func (m *MockStore) GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]database.UserChatProviderKey, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserChatProviderKeys", ctx, userID)
ret0, _ := ret[0].([]database.UserChatProviderKey)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserChatProviderKeys indicates an expected call of GetUserChatProviderKeys.
func (mr *MockStoreMockRecorder) GetUserChatProviderKeys(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatProviderKeys", reflect.TypeOf((*MockStore)(nil).GetUserChatProviderKeys), ctx, userID)
}
// GetUserChatSpendInPeriod mocks base method.
func (m *MockStore) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
m.ctrl.T.Helper()
@@ -4907,6 +5026,21 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(ctx, userID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), ctx, userID)
}
// GetUserSecret mocks base method.
func (m *MockStore) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserSecret", ctx, id)
ret0, _ := ret[0].(database.UserSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserSecret indicates an expected call of GetUserSecret.
func (mr *MockStoreMockRecorder) GetUserSecret(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecret", reflect.TypeOf((*MockStore)(nil).GetUserSecret), ctx, id)
}
// GetUserSecretByUserIDAndName mocks base method.
func (m *MockStore) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) {
m.ctrl.T.Helper()
@@ -6091,6 +6225,51 @@ func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg)
}
// InsertChatAutomation mocks base method.
func (m *MockStore) InsertChatAutomation(ctx context.Context, arg database.InsertChatAutomationParams) (database.ChatAutomation, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertChatAutomation", ctx, arg)
ret0, _ := ret[0].(database.ChatAutomation)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertChatAutomation indicates an expected call of InsertChatAutomation.
func (mr *MockStoreMockRecorder) InsertChatAutomation(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatAutomation", reflect.TypeOf((*MockStore)(nil).InsertChatAutomation), ctx, arg)
}
// InsertChatAutomationEvent mocks base method.
func (m *MockStore) InsertChatAutomationEvent(ctx context.Context, arg database.InsertChatAutomationEventParams) (database.ChatAutomationEvent, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertChatAutomationEvent", ctx, arg)
ret0, _ := ret[0].(database.ChatAutomationEvent)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertChatAutomationEvent indicates an expected call of InsertChatAutomationEvent.
func (mr *MockStoreMockRecorder) InsertChatAutomationEvent(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatAutomationEvent", reflect.TypeOf((*MockStore)(nil).InsertChatAutomationEvent), ctx, arg)
}
// InsertChatAutomationTrigger mocks base method.
func (m *MockStore) InsertChatAutomationTrigger(ctx context.Context, arg database.InsertChatAutomationTriggerParams) (database.ChatAutomationTrigger, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertChatAutomationTrigger", ctx, arg)
ret0, _ := ret[0].(database.ChatAutomationTrigger)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertChatAutomationTrigger indicates an expected call of InsertChatAutomationTrigger.
func (mr *MockStoreMockRecorder) InsertChatAutomationTrigger(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatAutomationTrigger", reflect.TypeOf((*MockStore)(nil).InsertChatAutomationTrigger), ctx, arg)
}
// InsertChatFile mocks base method.
func (m *MockStore) InsertChatFile(ctx context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
m.ctrl.T.Helper()
@@ -7066,21 +7245,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), ctx, arg)
}
// LinkChatFiles mocks base method.
func (m *MockStore) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LinkChatFiles", ctx, arg)
ret0, _ := ret[0].(int32)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LinkChatFiles indicates an expected call of LinkChatFiles.
func (mr *MockStoreMockRecorder) LinkChatFiles(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkChatFiles", reflect.TypeOf((*MockStore)(nil).LinkChatFiles), ctx, arg)
}
// ListAIBridgeClients mocks base method.
func (m *MockStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
m.ctrl.T.Helper()
@@ -7397,10 +7561,10 @@ func (mr *MockStoreMockRecorder) ListUserChatCompactionThresholds(ctx, userID an
}
// ListUserSecrets mocks base method.
func (m *MockStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) {
func (m *MockStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListUserSecrets", ctx, userID)
ret0, _ := ret[0].([]database.ListUserSecretsRow)
ret0, _ := ret[0].([]database.UserSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -7411,21 +7575,6 @@ func (mr *MockStoreMockRecorder) ListUserSecrets(ctx, userID any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecrets", reflect.TypeOf((*MockStore)(nil).ListUserSecrets), ctx, userID)
}
// ListUserSecretsWithValues mocks base method.
func (m *MockStore) ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListUserSecretsWithValues", ctx, userID)
ret0, _ := ret[0].([]database.UserSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListUserSecretsWithValues indicates an expected call of ListUserSecretsWithValues.
func (mr *MockStoreMockRecorder) ListUserSecretsWithValues(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecretsWithValues", reflect.TypeOf((*MockStore)(nil).ListUserSecretsWithValues), ctx, userID)
}
// ListWorkspaceAgentPortShares mocks base method.
func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) {
m.ctrl.T.Helper()
@@ -7574,6 +7723,21 @@ func (mr *MockStoreMockRecorder) PopNextQueuedMessage(ctx, chatID any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PopNextQueuedMessage", reflect.TypeOf((*MockStore)(nil).PopNextQueuedMessage), ctx, chatID)
}
// PurgeOldChatAutomationEvents mocks base method.
func (m *MockStore) PurgeOldChatAutomationEvents(ctx context.Context, arg database.PurgeOldChatAutomationEventsParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PurgeOldChatAutomationEvents", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PurgeOldChatAutomationEvents indicates an expected call of PurgeOldChatAutomationEvents.
func (mr *MockStoreMockRecorder) PurgeOldChatAutomationEvents(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PurgeOldChatAutomationEvents", reflect.TypeOf((*MockStore)(nil).PurgeOldChatAutomationEvents), ctx, arg)
}
// ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate mocks base method.
func (m *MockStore) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error {
m.ctrl.T.Helper()
@@ -7805,6 +7969,65 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg)
}
// UpdateChatAutomation mocks base method.
func (m *MockStore) UpdateChatAutomation(ctx context.Context, arg database.UpdateChatAutomationParams) (database.ChatAutomation, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatAutomation", ctx, arg)
ret0, _ := ret[0].(database.ChatAutomation)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatAutomation indicates an expected call of UpdateChatAutomation.
func (mr *MockStoreMockRecorder) UpdateChatAutomation(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatAutomation", reflect.TypeOf((*MockStore)(nil).UpdateChatAutomation), ctx, arg)
}
// UpdateChatAutomationTrigger mocks base method.
func (m *MockStore) UpdateChatAutomationTrigger(ctx context.Context, arg database.UpdateChatAutomationTriggerParams) (database.ChatAutomationTrigger, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatAutomationTrigger", ctx, arg)
ret0, _ := ret[0].(database.ChatAutomationTrigger)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatAutomationTrigger indicates an expected call of UpdateChatAutomationTrigger.
func (mr *MockStoreMockRecorder) UpdateChatAutomationTrigger(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatAutomationTrigger", reflect.TypeOf((*MockStore)(nil).UpdateChatAutomationTrigger), ctx, arg)
}
// UpdateChatAutomationTriggerLastTriggeredAt mocks base method.
func (m *MockStore) UpdateChatAutomationTriggerLastTriggeredAt(ctx context.Context, arg database.UpdateChatAutomationTriggerLastTriggeredAtParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatAutomationTriggerLastTriggeredAt", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateChatAutomationTriggerLastTriggeredAt indicates an expected call of UpdateChatAutomationTriggerLastTriggeredAt.
func (mr *MockStoreMockRecorder) UpdateChatAutomationTriggerLastTriggeredAt(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatAutomationTriggerLastTriggeredAt", reflect.TypeOf((*MockStore)(nil).UpdateChatAutomationTriggerLastTriggeredAt), ctx, arg)
}
// UpdateChatAutomationTriggerWebhookSecret mocks base method.
func (m *MockStore) UpdateChatAutomationTriggerWebhookSecret(ctx context.Context, arg database.UpdateChatAutomationTriggerWebhookSecretParams) (database.ChatAutomationTrigger, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatAutomationTriggerWebhookSecret", ctx, arg)
ret0, _ := ret[0].(database.ChatAutomationTrigger)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatAutomationTriggerWebhookSecret indicates an expected call of UpdateChatAutomationTriggerWebhookSecret.
func (mr *MockStoreMockRecorder) UpdateChatAutomationTriggerWebhookSecret(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatAutomationTriggerWebhookSecret", reflect.TypeOf((*MockStore)(nil).UpdateChatAutomationTriggerWebhookSecret), ctx, arg)
}
// UpdateChatBuildAgentBinding mocks base method.
func (m *MockStore) UpdateChatBuildAgentBinding(ctx context.Context, arg database.UpdateChatBuildAgentBindingParams) (database.Chat, error) {
m.ctrl.T.Helper()
@@ -7835,19 +8058,19 @@ func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg)
}
// UpdateChatHeartbeats mocks base method.
func (m *MockStore) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
// UpdateChatHeartbeat mocks base method.
func (m *MockStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatHeartbeats", ctx, arg)
ret0, _ := ret[0].([]uuid.UUID)
ret := m.ctrl.Call(m, "UpdateChatHeartbeat", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatHeartbeats indicates an expected call of UpdateChatHeartbeats.
func (mr *MockStoreMockRecorder) UpdateChatHeartbeats(ctx, arg any) *gomock.Call {
// UpdateChatHeartbeat indicates an expected call of UpdateChatHeartbeat.
func (mr *MockStoreMockRecorder) UpdateChatHeartbeat(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatHeartbeats", reflect.TypeOf((*MockStore)(nil).UpdateChatHeartbeats), ctx, arg)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateChatHeartbeat), ctx, arg)
}
// UpdateChatLabelsByID mocks base method.
@@ -8678,21 +8901,6 @@ func (mr *MockStoreMockRecorder) UpdateUserChatCustomPrompt(ctx, arg any) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChatCustomPrompt", reflect.TypeOf((*MockStore)(nil).UpdateUserChatCustomPrompt), ctx, arg)
}
// UpdateUserChatProviderKey mocks base method.
func (m *MockStore) UpdateUserChatProviderKey(ctx context.Context, arg database.UpdateUserChatProviderKeyParams) (database.UserChatProviderKey, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserChatProviderKey", ctx, arg)
ret0, _ := ret[0].(database.UserChatProviderKey)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserChatProviderKey indicates an expected call of UpdateUserChatProviderKey.
func (mr *MockStoreMockRecorder) UpdateUserChatProviderKey(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChatProviderKey", reflect.TypeOf((*MockStore)(nil).UpdateUserChatProviderKey), ctx, arg)
}
// UpdateUserDeletedByID mocks base method.
func (m *MockStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
@@ -8854,19 +9062,19 @@ func (mr *MockStoreMockRecorder) UpdateUserRoles(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserRoles", reflect.TypeOf((*MockStore)(nil).UpdateUserRoles), ctx, arg)
}
// UpdateUserSecretByUserIDAndName mocks base method.
func (m *MockStore) UpdateUserSecretByUserIDAndName(ctx context.Context, arg database.UpdateUserSecretByUserIDAndNameParams) (database.UserSecret, error) {
// UpdateUserSecret mocks base method.
func (m *MockStore) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserSecretByUserIDAndName", ctx, arg)
ret := m.ctrl.Call(m, "UpdateUserSecret", ctx, arg)
ret0, _ := ret[0].(database.UserSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserSecretByUserIDAndName indicates an expected call of UpdateUserSecretByUserIDAndName.
func (mr *MockStoreMockRecorder) UpdateUserSecretByUserIDAndName(ctx, arg any) *gomock.Call {
// UpdateUserSecret indicates an expected call of UpdateUserSecret.
func (mr *MockStoreMockRecorder) UpdateUserSecret(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).UpdateUserSecretByUserIDAndName), ctx, arg)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSecret", reflect.TypeOf((*MockStore)(nil).UpdateUserSecret), ctx, arg)
}
// UpdateUserStatus mocks base method.
@@ -9486,6 +9694,21 @@ func (mr *MockStoreMockRecorder) UpsertChatWorkspaceTTL(ctx, workspaceTtl any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpsertChatWorkspaceTTL), ctx, workspaceTtl)
}
// UpsertConnectionLog mocks base method.
func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertConnectionLog", ctx, arg)
ret0, _ := ret[0].(database.ConnectionLog)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpsertConnectionLog indicates an expected call of UpsertConnectionLog.
func (mr *MockStoreMockRecorder) UpsertConnectionLog(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertConnectionLog", reflect.TypeOf((*MockStore)(nil).UpsertConnectionLog), ctx, arg)
}
// UpsertDefaultProxy mocks base method.
func (m *MockStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error {
m.ctrl.T.Helper()
@@ -9744,21 +9967,6 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx)
}
// UpsertUserChatProviderKey mocks base method.
func (m *MockStore) UpsertUserChatProviderKey(ctx context.Context, arg database.UpsertUserChatProviderKeyParams) (database.UserChatProviderKey, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertUserChatProviderKey", ctx, arg)
ret0, _ := ret[0].(database.UserChatProviderKey)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpsertUserChatProviderKey indicates an expected call of UpsertUserChatProviderKey.
func (mr *MockStoreMockRecorder) UpsertUserChatProviderKey(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserChatProviderKey", reflect.TypeOf((*MockStore)(nil).UpsertUserChatProviderKey), ctx, arg)
}
// UpsertWebpushVAPIDKeys mocks base method.
func (m *MockStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error {
m.ctrl.T.Helper()
+191 -61
View File
@@ -220,7 +220,12 @@ CREATE TYPE api_key_scope AS ENUM (
'chat:read',
'chat:update',
'chat:delete',
'chat:*'
'chat:*',
'chat_automation:create',
'chat_automation:read',
'chat_automation:update',
'chat_automation:delete',
'chat_automation:*'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -270,6 +275,32 @@ CREATE TYPE build_reason AS ENUM (
'task_resume'
);
CREATE TYPE chat_automation_event_status AS ENUM (
'filtered',
'preview',
'created',
'continued',
'rate_limited',
'error'
);
COMMENT ON TYPE chat_automation_event_status IS 'Outcome of a chat automation event: filtered, preview, created, continued, rate_limited, or error.';
CREATE TYPE chat_automation_status AS ENUM (
'disabled',
'preview',
'active'
);
COMMENT ON TYPE chat_automation_status IS 'Lifecycle state of a chat automation: disabled, preview, or active.';
CREATE TYPE chat_automation_trigger_type AS ENUM (
'webhook',
'cron'
);
COMMENT ON TYPE chat_automation_trigger_type IS 'Discriminator for chat automation triggers: webhook or cron.';
CREATE TYPE chat_message_role AS ENUM (
'system',
'user',
@@ -1100,8 +1131,7 @@ 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,
provider_name text DEFAULT ''::text 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
);
COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge';
@@ -1116,8 +1146,6 @@ 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,
@@ -1134,9 +1162,7 @@ CREATE TABLE aibridge_token_usages (
input_tokens bigint NOT NULL,
output_tokens bigint NOT NULL,
metadata jsonb,
created_at timestamp with time zone NOT NULL,
cache_read_input_tokens bigint DEFAULT 0 NOT NULL,
cache_write_input_tokens bigint DEFAULT 0 NOT NULL
created_at timestamp with time zone NOT NULL
);
COMMENT ON TABLE aibridge_token_usages IS 'Audit log of tokens used by intercepted requests in AI Bridge';
@@ -1243,6 +1269,104 @@ COMMENT ON COLUMN boundary_usage_stats.window_start IS 'Start of the time window
COMMENT ON COLUMN boundary_usage_stats.updated_at IS 'Timestamp of the last update to this row.';
CREATE TABLE chat_automation_events (
id uuid NOT NULL,
automation_id uuid NOT NULL,
trigger_id uuid,
received_at timestamp with time zone NOT NULL,
payload jsonb NOT NULL,
filter_matched boolean NOT NULL,
resolved_labels jsonb,
matched_chat_id uuid,
created_chat_id uuid,
status chat_automation_event_status NOT NULL,
error text,
CONSTRAINT chat_automation_events_chat_exclusivity CHECK (((matched_chat_id IS NULL) OR (created_chat_id IS NULL)))
);
COMMENT ON TABLE chat_automation_events IS 'Every trigger invocation produces an event row regardless of outcome. This table is the audit trail and the data source for rate-limit window counts. Rows are append-only and expected to be purged by a background job after a retention period.';
COMMENT ON COLUMN chat_automation_events.payload IS 'The raw payload that was evaluated. For webhooks this is the HTTP body; for cron triggers it is a synthetic JSON envelope with schedule metadata.';
COMMENT ON COLUMN chat_automation_events.filter_matched IS 'Whether the trigger filter conditions matched. False means the event was dropped before any chat interaction.';
COMMENT ON COLUMN chat_automation_events.resolved_labels IS 'Labels resolved from the payload via label_paths. Stored so the event log shows exactly which labels were computed.';
COMMENT ON COLUMN chat_automation_events.matched_chat_id IS 'ID of an existing chat that was found via label matching and continued with a new message.';
COMMENT ON COLUMN chat_automation_events.created_chat_id IS 'ID of a newly created chat (mutually exclusive with matched_chat_id in practice).';
COMMENT ON COLUMN chat_automation_events.status IS 'Outcome of the event: filtered — filter did not match; preview — automation is in preview mode; created — new chat was created; continued — existing chat was continued; rate_limited — rate limit prevented chat action; error — something went wrong.';
CREATE TABLE chat_automation_triggers (
id uuid NOT NULL,
automation_id uuid NOT NULL,
type chat_automation_trigger_type NOT NULL,
webhook_secret text,
webhook_secret_key_id text,
cron_schedule text,
last_triggered_at timestamp with time zone,
filter jsonb,
label_paths jsonb,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
CONSTRAINT chat_automation_triggers_cron_fields CHECK (((type <> 'cron'::chat_automation_trigger_type) OR ((cron_schedule IS NOT NULL) AND (webhook_secret IS NULL) AND (webhook_secret_key_id IS NULL)))),
CONSTRAINT chat_automation_triggers_webhook_fields CHECK (((type <> 'webhook'::chat_automation_trigger_type) OR ((webhook_secret IS NOT NULL) AND (cron_schedule IS NULL) AND (last_triggered_at IS NULL))))
);
COMMENT ON TABLE chat_automation_triggers IS 'Triggers define how an automation is invoked. Each automation can have multiple triggers (e.g. one webhook + one cron schedule). Webhook and cron triggers share the same row shape with type-specific nullable columns to keep the schema simple.';
COMMENT ON COLUMN chat_automation_triggers.type IS 'Discriminator: webhook or cron. Determines which nullable columns are meaningful.';
COMMENT ON COLUMN chat_automation_triggers.webhook_secret IS 'HMAC-SHA256 shared secret for webhook signature verification (X-Hub-Signature-256 header). NULL for cron triggers.';
COMMENT ON COLUMN chat_automation_triggers.cron_schedule IS 'Standard 5-field cron expression (minute hour dom month dow), with optional CRON_TZ= prefix. NULL for webhook triggers.';
COMMENT ON COLUMN chat_automation_triggers.last_triggered_at IS 'Timestamp of the last successful cron fire. The scheduler computes next = cron.Next(last_triggered_at) and fires when next <= now. NULL means the trigger has never fired. Not used for webhook triggers.';
COMMENT ON COLUMN chat_automation_triggers.filter IS 'gjson path-to-value filter conditions evaluated against the incoming webhook payload. All conditions must match for the trigger to fire. NULL or empty means match everything.';
COMMENT ON COLUMN chat_automation_triggers.label_paths IS 'Maps chat label keys to gjson paths. When a trigger fires, labels are resolved from the payload and used to find an existing chat to continue (by label match) or set on a newly created chat.';
CREATE TABLE chat_automations (
id uuid NOT NULL,
owner_id uuid NOT NULL,
organization_id uuid NOT NULL,
name text NOT NULL,
description text DEFAULT ''::text NOT NULL,
instructions text DEFAULT ''::text NOT NULL,
model_config_id uuid,
mcp_server_ids uuid[] DEFAULT '{}'::uuid[] NOT NULL,
allowed_tools text[] DEFAULT '{}'::text[] NOT NULL,
status chat_automation_status DEFAULT 'disabled'::chat_automation_status NOT NULL,
max_chat_creates_per_hour integer DEFAULT 10 NOT NULL,
max_messages_per_hour integer DEFAULT 60 NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
CONSTRAINT chat_automations_max_chat_creates_per_hour_check CHECK ((max_chat_creates_per_hour > 0)),
CONSTRAINT chat_automations_max_messages_per_hour_check CHECK ((max_messages_per_hour > 0))
);
COMMENT ON TABLE chat_automations IS 'Chat automations bridge external events (webhooks, cron schedules) to Coder chats. A chat automation defines what to say, which model and tools to use, and how fast it is allowed to create or continue chats.';
COMMENT ON COLUMN chat_automations.owner_id IS 'The user on whose behalf chats are created. All RBAC checks and chat ownership are scoped to this user.';
COMMENT ON COLUMN chat_automations.organization_id IS 'Organization scope for RBAC. Combined with owner_id and name to form a unique constraint so automations are namespaced per user per org.';
COMMENT ON COLUMN chat_automations.instructions IS 'The user-role message injected into every chat this automation creates. This is the core prompt that tells the LLM what to do.';
COMMENT ON COLUMN chat_automations.model_config_id IS 'Optional model configuration override. When NULL the deployment default is used. SET NULL on delete so automations survive config changes gracefully.';
COMMENT ON COLUMN chat_automations.mcp_server_ids IS 'MCP servers to attach to chats created by this automation. Stored as an array of UUIDs rather than a join table because the set is small and always read/written atomically.';
COMMENT ON COLUMN chat_automations.allowed_tools IS 'Tool allowlist. Empty means all tools available to the model config are permitted.';
COMMENT ON COLUMN chat_automations.status IS 'Lifecycle state: disabled — trigger events are silently dropped; preview — events are logged but no chat is created (dry-run); active — events create or continue chats.';
COMMENT ON COLUMN chat_automations.max_chat_creates_per_hour IS 'Maximum number of new chats this automation may create in a rolling one-hour window. Prevents runaway webhook storms from flooding the system.';
COMMENT ON COLUMN chat_automations.max_messages_per_hour IS 'Maximum total messages (creates + continues) this automation may send in a rolling one-hour window. A second, broader throttle that catches high-frequency continuation patterns.';
CREATE TABLE chat_diff_statuses (
chat_id uuid NOT NULL,
url text,
@@ -1269,11 +1393,6 @@ CREATE TABLE chat_diff_statuses (
head_branch text
);
CREATE TABLE chat_file_links (
chat_id uuid NOT NULL,
file_id uuid NOT NULL
);
CREATE TABLE chat_files (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
@@ -1348,11 +1467,7 @@ CREATE TABLE chat_providers (
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
base_url text DEFAULT ''::text NOT NULL,
central_api_key_enabled boolean DEFAULT true NOT NULL,
allow_user_api_key boolean DEFAULT false NOT NULL,
allow_central_api_key_fallback boolean DEFAULT false NOT NULL,
CONSTRAINT chat_providers_provider_check CHECK ((provider = ANY (ARRAY['anthropic'::text, 'azure'::text, 'bedrock'::text, 'google'::text, 'openai'::text, 'openai-compat'::text, 'openrouter'::text, 'vercel'::text]))),
CONSTRAINT valid_credential_policy CHECK (((central_api_key_enabled OR allow_user_api_key) AND ((NOT allow_central_api_key_fallback) OR (central_api_key_enabled AND allow_user_api_key))))
CONSTRAINT chat_providers_provider_check CHECK ((provider = ANY (ARRAY['anthropic'::text, 'azure'::text, 'bedrock'::text, 'google'::text, 'openai'::text, 'openai-compat'::text, 'openrouter'::text, 'vercel'::text])))
);
COMMENT ON COLUMN chat_providers.api_key_key_id IS 'The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted';
@@ -1418,7 +1533,8 @@ CREATE TABLE chats (
agent_id uuid,
pin_order integer DEFAULT 0 NOT NULL,
last_read_message_id bigint,
last_injected_context jsonb
last_injected_context jsonb,
automation_id uuid
);
CREATE TABLE connection_logs (
@@ -2763,17 +2879,6 @@ COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of
COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.';
CREATE TABLE user_chat_provider_keys (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL,
chat_provider_id uuid NOT NULL,
api_key text NOT NULL,
api_key_key_id text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_chat_provider_keys_api_key_check CHECK ((api_key <> ''::text))
);
CREATE TABLE user_configs (
user_id uuid NOT NULL,
key character varying(256) NOT NULL,
@@ -2815,8 +2920,7 @@ CREATE TABLE user_secrets (
env_name text DEFAULT ''::text NOT NULL,
file_path text DEFAULT ''::text NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
value_key_id text
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE user_status_changes (
@@ -3346,12 +3450,18 @@ ALTER TABLE ONLY audit_logs
ALTER TABLE ONLY boundary_usage_stats
ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id);
ALTER TABLE ONLY chat_automation_events
ADD CONSTRAINT chat_automation_events_pkey PRIMARY KEY (id);
ALTER TABLE ONLY chat_automation_triggers
ADD CONSTRAINT chat_automation_triggers_pkey PRIMARY KEY (id);
ALTER TABLE ONLY chat_automations
ADD CONSTRAINT chat_automations_pkey PRIMARY KEY (id);
ALTER TABLE ONLY chat_diff_statuses
ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id);
ALTER TABLE ONLY chat_file_links
ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id);
ALTER TABLE ONLY chat_files
ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id);
@@ -3574,12 +3684,6 @@ ALTER TABLE ONLY usage_events_daily
ALTER TABLE ONLY usage_events
ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_chat_provider_keys
ADD CONSTRAINT user_chat_provider_keys_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_chat_provider_keys
ADD CONSTRAINT user_chat_provider_keys_user_id_chat_provider_id_key UNIQUE (user_id, chat_provider_id);
ALTER TABLE ONLY user_configs
ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key);
@@ -3740,9 +3844,21 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id);
CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC);
CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at);
CREATE INDEX idx_chat_automation_events_automation_id_received_at ON chat_automation_events USING btree (automation_id, received_at DESC);
CREATE INDEX idx_chat_file_links_chat_id ON chat_file_links USING btree (chat_id);
CREATE INDEX idx_chat_automation_events_rate_limit ON chat_automation_events USING btree (automation_id, received_at) WHERE (status = ANY (ARRAY['created'::chat_automation_event_status, 'continued'::chat_automation_event_status]));
CREATE INDEX idx_chat_automation_events_received_at ON chat_automation_events USING btree (received_at);
CREATE INDEX idx_chat_automation_triggers_automation_id ON chat_automation_triggers USING btree (automation_id);
CREATE INDEX idx_chat_automations_organization_id ON chat_automations USING btree (organization_id);
CREATE INDEX idx_chat_automations_owner_id ON chat_automations USING btree (owner_id);
CREATE UNIQUE INDEX idx_chat_automations_owner_org_name ON chat_automations USING btree (owner_id, organization_id, name);
CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at);
CREATE INDEX idx_chat_files_org ON chat_files USING btree (organization_id);
@@ -3770,6 +3886,8 @@ CREATE INDEX idx_chat_providers_enabled ON chat_providers USING btree (enabled);
CREATE INDEX idx_chat_queued_messages_chat_id ON chat_queued_messages USING btree (chat_id);
CREATE INDEX idx_chats_automation_id ON chats USING btree (automation_id);
CREATE INDEX idx_chats_labels ON chats USING gin (labels);
CREATE INDEX idx_chats_last_model_config_id ON chats USING btree (last_model_config_id);
@@ -4043,15 +4161,36 @@ ALTER TABLE ONLY aibridge_interceptions
ALTER TABLE ONLY api_keys
ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_automation_events
ADD CONSTRAINT chat_automation_events_automation_id_fkey FOREIGN KEY (automation_id) REFERENCES chat_automations(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_automation_events
ADD CONSTRAINT chat_automation_events_created_chat_id_fkey FOREIGN KEY (created_chat_id) REFERENCES chats(id) ON DELETE SET NULL;
ALTER TABLE ONLY chat_automation_events
ADD CONSTRAINT chat_automation_events_matched_chat_id_fkey FOREIGN KEY (matched_chat_id) REFERENCES chats(id) ON DELETE SET NULL;
ALTER TABLE ONLY chat_automation_events
ADD CONSTRAINT chat_automation_events_trigger_id_fkey FOREIGN KEY (trigger_id) REFERENCES chat_automation_triggers(id) ON DELETE SET NULL;
ALTER TABLE ONLY chat_automation_triggers
ADD CONSTRAINT chat_automation_triggers_automation_id_fkey FOREIGN KEY (automation_id) REFERENCES chat_automations(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_automation_triggers
ADD CONSTRAINT chat_automation_triggers_webhook_secret_key_id_fkey FOREIGN KEY (webhook_secret_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY chat_automations
ADD CONSTRAINT chat_automations_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id) ON DELETE SET NULL;
ALTER TABLE ONLY chat_automations
ADD CONSTRAINT chat_automations_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_automations
ADD CONSTRAINT chat_automations_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_diff_statuses
ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_file_links
ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_file_links
ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_files
ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
@@ -4085,6 +4224,9 @@ ALTER TABLE ONLY chat_queued_messages
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_automation_id_fkey FOREIGN KEY (automation_id) REFERENCES chat_automations(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;
@@ -4298,15 +4440,6 @@ ALTER TABLE ONLY templates
ALTER TABLE ONLY templates
ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_chat_provider_keys
ADD CONSTRAINT user_chat_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY user_chat_provider_keys
ADD CONSTRAINT user_chat_provider_keys_chat_provider_id_fkey FOREIGN KEY (chat_provider_id) REFERENCES chat_providers(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_chat_provider_keys
ADD CONSTRAINT user_chat_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_configs
ADD CONSTRAINT user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@@ -4325,9 +4458,6 @@ ALTER TABLE ONLY user_links
ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY user_status_changes
ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
+10 -6
View File
@@ -9,9 +9,16 @@ const (
ForeignKeyAiSeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatAutomationEventsAutomationID ForeignKeyConstraint = "chat_automation_events_automation_id_fkey" // ALTER TABLE ONLY chat_automation_events ADD CONSTRAINT chat_automation_events_automation_id_fkey FOREIGN KEY (automation_id) REFERENCES chat_automations(id) ON DELETE CASCADE;
ForeignKeyChatAutomationEventsCreatedChatID ForeignKeyConstraint = "chat_automation_events_created_chat_id_fkey" // ALTER TABLE ONLY chat_automation_events ADD CONSTRAINT chat_automation_events_created_chat_id_fkey FOREIGN KEY (created_chat_id) REFERENCES chats(id) ON DELETE SET NULL;
ForeignKeyChatAutomationEventsMatchedChatID ForeignKeyConstraint = "chat_automation_events_matched_chat_id_fkey" // ALTER TABLE ONLY chat_automation_events ADD CONSTRAINT chat_automation_events_matched_chat_id_fkey FOREIGN KEY (matched_chat_id) REFERENCES chats(id) ON DELETE SET NULL;
ForeignKeyChatAutomationEventsTriggerID ForeignKeyConstraint = "chat_automation_events_trigger_id_fkey" // ALTER TABLE ONLY chat_automation_events ADD CONSTRAINT chat_automation_events_trigger_id_fkey FOREIGN KEY (trigger_id) REFERENCES chat_automation_triggers(id) ON DELETE SET NULL;
ForeignKeyChatAutomationTriggersAutomationID ForeignKeyConstraint = "chat_automation_triggers_automation_id_fkey" // ALTER TABLE ONLY chat_automation_triggers ADD CONSTRAINT chat_automation_triggers_automation_id_fkey FOREIGN KEY (automation_id) REFERENCES chat_automations(id) ON DELETE CASCADE;
ForeignKeyChatAutomationTriggersWebhookSecretKeyID ForeignKeyConstraint = "chat_automation_triggers_webhook_secret_key_id_fkey" // ALTER TABLE ONLY chat_automation_triggers ADD CONSTRAINT chat_automation_triggers_webhook_secret_key_id_fkey FOREIGN KEY (webhook_secret_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyChatAutomationsModelConfigID ForeignKeyConstraint = "chat_automations_model_config_id_fkey" // ALTER TABLE ONLY chat_automations ADD CONSTRAINT chat_automations_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id) ON DELETE SET NULL;
ForeignKeyChatAutomationsOrganizationID ForeignKeyConstraint = "chat_automations_organization_id_fkey" // ALTER TABLE ONLY chat_automations ADD CONSTRAINT chat_automations_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyChatAutomationsOwnerID ForeignKeyConstraint = "chat_automations_owner_id_fkey" // ALTER TABLE ONLY chat_automations ADD CONSTRAINT chat_automations_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatFileLinksChatID ForeignKeyConstraint = "chat_file_links_chat_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatFileLinksFileID ForeignKeyConstraint = "chat_file_links_file_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE;
ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
@@ -23,6 +30,7 @@ const (
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;
ForeignKeyChatsAutomationID ForeignKeyConstraint = "chats_automation_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_automation_id_fkey FOREIGN KEY (automation_id) REFERENCES chat_automations(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;
@@ -94,16 +102,12 @@ const (
ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE;
ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT;
ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyUserChatProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_chat_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_chat_provider_keys ADD CONSTRAINT user_chat_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyUserChatProviderKeysChatProviderID ForeignKeyConstraint = "user_chat_provider_keys_chat_provider_id_fkey" // ALTER TABLE ONLY user_chat_provider_keys ADD CONSTRAINT user_chat_provider_keys_chat_provider_id_fkey FOREIGN KEY (chat_provider_id) REFERENCES chat_providers(id) ON DELETE CASCADE;
ForeignKeyUserChatProviderKeysUserID ForeignKeyConstraint = "user_chat_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_chat_provider_keys ADD CONSTRAINT user_chat_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserConfigsUserID ForeignKeyConstraint = "user_configs_user_id_fkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserDeletedUserID ForeignKeyConstraint = "user_deleted_user_id_fkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
@@ -27,6 +27,7 @@ func TestCustomQueriesSyncedRowScan(t *testing.T) {
"GetWorkspaces": "GetAuthorizedWorkspaces",
"GetUsers": "GetAuthorizedUsers",
"GetChats": "GetAuthorizedChats",
"GetChatAutomations": "GetAuthorizedChatAutomations",
}
// Scan custom
+3
View File
@@ -15,6 +15,9 @@ const (
LockIDReconcilePrebuilds
LockIDReconcileSystemRoles
LockIDBoundaryUsageStats
// LockIDChatAutomationCron prevents concurrent cron trigger
// evaluation across coderd replicas.
LockIDChatAutomationCron
)
// GenLockID generates a unique and consistent lock ID from a given string.
@@ -1 +0,0 @@
ALTER TABLE aibridge_interceptions DROP COLUMN provider_name;
@@ -1,6 +0,0 @@
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 = '';
@@ -0,0 +1,13 @@
ALTER TABLE chats DROP COLUMN IF EXISTS automation_id;
DROP TABLE IF EXISTS chat_automation_events;
DROP TABLE IF EXISTS chat_automation_triggers;
DROP TABLE IF EXISTS chat_automations;
DROP TYPE IF EXISTS chat_automation_event_status;
DROP TYPE IF EXISTS chat_automation_trigger_type;
DROP TYPE IF EXISTS chat_automation_status;
@@ -0,0 +1,238 @@
-- Chat automations bridge external events (webhooks, cron schedules) to
-- Coder chats. A chat automation defines *what* to say, *which* model
-- and tools to use, and *how fast* it is allowed to create or continue
-- chats.
CREATE TYPE chat_automation_status AS ENUM ('disabled', 'preview', 'active');
CREATE TYPE chat_automation_trigger_type AS ENUM ('webhook', 'cron');
CREATE TYPE chat_automation_event_status AS ENUM ('filtered', 'preview', 'created', 'continued', 'rate_limited', 'error');
CREATE TABLE chat_automations (
id uuid NOT NULL,
-- The user on whose behalf chats are created. All RBAC checks and
-- chat ownership are scoped to this user.
owner_id uuid NOT NULL,
-- Organization scope for RBAC. Combined with owner_id and name to
-- form a unique constraint so automations are namespaced per user
-- per org.
organization_id uuid NOT NULL,
-- Human-readable identifier. Unique within (owner_id, organization_id).
name text NOT NULL,
-- Optional long-form description shown in the UI.
description text NOT NULL DEFAULT '',
-- The user-role message injected into every chat this automation
-- creates. This is the core prompt that tells the LLM what to do.
instructions text NOT NULL DEFAULT '',
-- Optional model configuration override. When NULL the deployment
-- default is used. SET NULL on delete so automations survive config
-- changes gracefully.
model_config_id uuid,
-- MCP servers to attach to chats created by this automation.
-- Stored as an array of UUIDs rather than a join table because
-- the set is small and always read/written atomically.
mcp_server_ids uuid[] NOT NULL DEFAULT '{}',
-- Tool allowlist. Empty means all tools available to the model
-- config are permitted.
allowed_tools text[] NOT NULL DEFAULT '{}',
-- Lifecycle state:
-- disabled — trigger events are silently dropped.
-- preview — events are logged but no chat is created (dry-run).
-- active — events create or continue chats.
status chat_automation_status NOT NULL DEFAULT 'disabled',
-- Maximum number of *new* chats this automation may create in a
-- rolling one-hour window. Prevents runaway webhook storms from
-- flooding the system. Approximate under concurrency; the
-- check-then-insert is not serialized, so brief bursts may
-- slightly exceed the cap.
max_chat_creates_per_hour integer NOT NULL DEFAULT 10,
-- Maximum total messages (creates + continues) this automation may
-- send in a rolling one-hour window. A second, broader throttle
-- that catches high-frequency continuation patterns. Same
-- approximate-under-concurrency caveat as above.
max_messages_per_hour integer NOT NULL DEFAULT 60,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id) ON DELETE SET NULL,
CONSTRAINT chat_automations_max_chat_creates_per_hour_check CHECK (max_chat_creates_per_hour > 0),
CONSTRAINT chat_automations_max_messages_per_hour_check CHECK (max_messages_per_hour > 0)
);
CREATE INDEX idx_chat_automations_owner_id ON chat_automations (owner_id);
CREATE INDEX idx_chat_automations_organization_id ON chat_automations (organization_id);
-- Enforces that automation names are unique per user per org so they
-- can be referenced unambiguously in CLI/API calls.
CREATE UNIQUE INDEX idx_chat_automations_owner_org_name ON chat_automations (owner_id, organization_id, name);
-- Triggers define *how* an automation is invoked. Each automation can
-- have multiple triggers (e.g. one webhook + one cron schedule).
-- Webhook and cron triggers share the same row shape with type-specific
-- nullable columns to keep the schema simple.
CREATE TABLE chat_automation_triggers (
id uuid NOT NULL,
-- Parent automation. CASCADE delete ensures orphan triggers are
-- cleaned up when an automation is removed.
automation_id uuid NOT NULL,
-- Discriminator: 'webhook' or 'cron'. Determines which nullable
-- columns are meaningful.
type chat_automation_trigger_type NOT NULL,
-- HMAC-SHA256 shared secret for webhook signature verification
-- (X-Hub-Signature-256 header). NULL for cron triggers.
webhook_secret text,
-- Identifier of the dbcrypt key used to encrypt webhook_secret.
-- NULL means the secret is not yet encrypted. When dbcrypt is
-- enabled, this references the active key digest used for
-- AES-256-GCM encryption.
webhook_secret_key_id text REFERENCES dbcrypt_keys(active_key_digest),
-- Standard 5-field cron expression (minute hour dom month dow),
-- with optional CRON_TZ= prefix. NULL for webhook triggers.
cron_schedule text,
-- Timestamp of the last successful cron fire. The scheduler
-- computes next = cron.Next(last_triggered_at) and fires when
-- next <= now. NULL means the trigger has never fired; the
-- scheduler falls back to created_at as the reference time.
-- Not used for webhook triggers.
last_triggered_at timestamp with time zone,
-- gjson path→value filter conditions evaluated against the
-- incoming webhook payload. All conditions must match for the
-- trigger to fire. NULL or empty means "match everything".
filter jsonb,
-- Maps chat label keys to gjson paths. When a trigger fires,
-- labels are resolved from the payload and used to find an
-- existing chat to continue (by label match) or set on a
-- newly created chat. This is how automations route events
-- to the right conversation.
label_paths jsonb,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (automation_id) REFERENCES chat_automations(id) ON DELETE CASCADE,
CONSTRAINT chat_automation_triggers_webhook_fields CHECK (
type != 'webhook' OR (webhook_secret IS NOT NULL AND cron_schedule IS NULL AND last_triggered_at IS NULL)
),
CONSTRAINT chat_automation_triggers_cron_fields CHECK (
type != 'cron' OR (cron_schedule IS NOT NULL AND webhook_secret IS NULL AND webhook_secret_key_id IS NULL)
)
);
CREATE INDEX idx_chat_automation_triggers_automation_id ON chat_automation_triggers (automation_id);
-- Every trigger invocation produces an event row regardless of outcome.
-- This table is the audit trail and the data source for rate-limit
-- window counts. Rows are append-only and expected to be purged by a
-- background job after a retention period.
CREATE TABLE chat_automation_events (
id uuid NOT NULL,
-- The automation that owns this event.
automation_id uuid NOT NULL,
-- The trigger that produced this event. SET NULL on delete so
-- historical events survive trigger removal.
trigger_id uuid,
-- When the event was received (webhook delivery time or cron
-- evaluation time). Used for rate-limit window calculations and
-- purge cutoffs.
received_at timestamp with time zone NOT NULL,
-- The raw payload that was evaluated. For webhooks this is the
-- HTTP body; for cron triggers it is a synthetic JSON envelope
-- with schedule metadata.
payload jsonb NOT NULL,
-- Whether the trigger's filter conditions matched. False means
-- the event was dropped before any chat interaction.
filter_matched boolean NOT NULL,
-- Labels resolved from the payload via label_paths. Stored so
-- the event log shows exactly which labels were computed.
resolved_labels jsonb,
-- ID of an existing chat that was found via label matching and
-- continued with a new message.
matched_chat_id uuid,
-- ID of a newly created chat (mutually exclusive with
-- matched_chat_id in practice).
created_chat_id uuid,
-- Outcome of the event:
-- filtered — filter did not match, event dropped.
-- preview — automation is in preview mode, no chat action.
-- created — new chat was created.
-- continued — existing chat was continued.
-- rate_limited — rate limit prevented chat action.
-- error — something went wrong (see error column).
status chat_automation_event_status NOT NULL,
-- Human-readable error description when status = 'error' or
-- 'rate_limited'. NULL for successful outcomes.
error text,
PRIMARY KEY (id),
FOREIGN KEY (automation_id) REFERENCES chat_automations(id) ON DELETE CASCADE,
FOREIGN KEY (trigger_id) REFERENCES chat_automation_triggers(id) ON DELETE SET NULL,
FOREIGN KEY (matched_chat_id) REFERENCES chats(id) ON DELETE SET NULL,
FOREIGN KEY (created_chat_id) REFERENCES chats(id) ON DELETE SET NULL,
CONSTRAINT chat_automation_events_chat_exclusivity CHECK (
matched_chat_id IS NULL OR created_chat_id IS NULL
)
);
-- Composite index for listing events per automation in reverse
-- chronological order (the primary UI query pattern).
CREATE INDEX idx_chat_automation_events_automation_id_received_at ON chat_automation_events (automation_id, received_at DESC);
-- Standalone index on received_at for the purge job, which deletes
-- events older than the retention period across all automations.
CREATE INDEX idx_chat_automation_events_received_at ON chat_automation_events (received_at);
-- Partial index for rate-limit window count queries, which filter
-- by automation_id and status IN ('created', 'continued').
CREATE INDEX idx_chat_automation_events_rate_limit
ON chat_automation_events (automation_id, received_at)
WHERE status IN ('created', 'continued');
-- Link chats back to the automation that created them. SET NULL on
-- delete so chats survive if the automation is removed. Indexed for
-- lookup queries that list chats spawned by a given automation.
ALTER TABLE chats ADD COLUMN automation_id uuid REFERENCES chat_automations(id) ON DELETE SET NULL;
CREATE INDEX idx_chats_automation_id ON chats (automation_id);
-- Enum type comments.
COMMENT ON TYPE chat_automation_status IS 'Lifecycle state of a chat automation: disabled, preview, or active.';
COMMENT ON TYPE chat_automation_trigger_type IS 'Discriminator for chat automation triggers: webhook or cron.';
COMMENT ON TYPE chat_automation_event_status IS 'Outcome of a chat automation event: filtered, preview, created, continued, rate_limited, or error.';
-- Table comments.
COMMENT ON TABLE chat_automations IS 'Chat automations bridge external events (webhooks, cron schedules) to Coder chats. A chat automation defines what to say, which model and tools to use, and how fast it is allowed to create or continue chats.';
COMMENT ON TABLE chat_automation_triggers IS 'Triggers define how an automation is invoked. Each automation can have multiple triggers (e.g. one webhook + one cron schedule). Webhook and cron triggers share the same row shape with type-specific nullable columns to keep the schema simple.';
COMMENT ON TABLE chat_automation_events IS 'Every trigger invocation produces an event row regardless of outcome. This table is the audit trail and the data source for rate-limit window counts. Rows are append-only and expected to be purged by a background job after a retention period.';
-- Column comments for chat_automations.
COMMENT ON COLUMN chat_automations.owner_id IS 'The user on whose behalf chats are created. All RBAC checks and chat ownership are scoped to this user.';
COMMENT ON COLUMN chat_automations.organization_id IS 'Organization scope for RBAC. Combined with owner_id and name to form a unique constraint so automations are namespaced per user per org.';
COMMENT ON COLUMN chat_automations.instructions IS 'The user-role message injected into every chat this automation creates. This is the core prompt that tells the LLM what to do.';
COMMENT ON COLUMN chat_automations.model_config_id IS 'Optional model configuration override. When NULL the deployment default is used. SET NULL on delete so automations survive config changes gracefully.';
COMMENT ON COLUMN chat_automations.mcp_server_ids IS 'MCP servers to attach to chats created by this automation. Stored as an array of UUIDs rather than a join table because the set is small and always read/written atomically.';
COMMENT ON COLUMN chat_automations.allowed_tools IS 'Tool allowlist. Empty means all tools available to the model config are permitted.';
COMMENT ON COLUMN chat_automations.status IS 'Lifecycle state: disabled — trigger events are silently dropped; preview — events are logged but no chat is created (dry-run); active — events create or continue chats.';
COMMENT ON COLUMN chat_automations.max_chat_creates_per_hour IS 'Maximum number of new chats this automation may create in a rolling one-hour window. Prevents runaway webhook storms from flooding the system.';
COMMENT ON COLUMN chat_automations.max_messages_per_hour IS 'Maximum total messages (creates + continues) this automation may send in a rolling one-hour window. A second, broader throttle that catches high-frequency continuation patterns.';
-- Column comments for chat_automation_triggers.
COMMENT ON COLUMN chat_automation_triggers.type IS 'Discriminator: webhook or cron. Determines which nullable columns are meaningful.';
COMMENT ON COLUMN chat_automation_triggers.webhook_secret IS 'HMAC-SHA256 shared secret for webhook signature verification (X-Hub-Signature-256 header). NULL for cron triggers.';
COMMENT ON COLUMN chat_automation_triggers.cron_schedule IS 'Standard 5-field cron expression (minute hour dom month dow), with optional CRON_TZ= prefix. NULL for webhook triggers.';
COMMENT ON COLUMN chat_automation_triggers.filter IS 'gjson path-to-value filter conditions evaluated against the incoming webhook payload. All conditions must match for the trigger to fire. NULL or empty means match everything.';
COMMENT ON COLUMN chat_automation_triggers.label_paths IS 'Maps chat label keys to gjson paths. When a trigger fires, labels are resolved from the payload and used to find an existing chat to continue (by label match) or set on a newly created chat.';
COMMENT ON COLUMN chat_automation_triggers.last_triggered_at IS 'Timestamp of the last successful cron fire. The scheduler computes next = cron.Next(last_triggered_at) and fires when next <= now. NULL means the trigger has never fired. Not used for webhook triggers.';
-- Column comments for chat_automation_events.
COMMENT ON COLUMN chat_automation_events.payload IS 'The raw payload that was evaluated. For webhooks this is the HTTP body; for cron triggers it is a synthetic JSON envelope with schedule metadata.';
COMMENT ON COLUMN chat_automation_events.filter_matched IS 'Whether the trigger filter conditions matched. False means the event was dropped before any chat interaction.';
COMMENT ON COLUMN chat_automation_events.resolved_labels IS 'Labels resolved from the payload via label_paths. Stored so the event log shows exactly which labels were computed.';
COMMENT ON COLUMN chat_automation_events.matched_chat_id IS 'ID of an existing chat that was found via label matching and continued with a new message.';
COMMENT ON COLUMN chat_automation_events.created_chat_id IS 'ID of a newly created chat (mutually exclusive with matched_chat_id in practice).';
COMMENT ON COLUMN chat_automation_events.status IS 'Outcome of the event: filtered — filter did not match; preview — automation is in preview mode; created — new chat was created; continued — existing chat was continued; rate_limited — rate limit prevented chat action; error — something went wrong.';
-- Add API key scope values for the new chat_automation resource type.
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_automation:create';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_automation:read';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_automation:update';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_automation:delete';
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_automation:*';
@@ -1,8 +0,0 @@
DROP TABLE IF EXISTS user_chat_provider_keys;
ALTER TABLE chat_providers DROP CONSTRAINT IF EXISTS valid_credential_policy;
ALTER TABLE chat_providers
DROP COLUMN IF EXISTS central_api_key_enabled,
DROP COLUMN IF EXISTS allow_user_api_key,
DROP COLUMN IF EXISTS allow_central_api_key_fallback;
@@ -1,24 +0,0 @@
ALTER TABLE chat_providers
ADD COLUMN central_api_key_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN allow_user_api_key BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN allow_central_api_key_fallback BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE chat_providers
ADD CONSTRAINT valid_credential_policy CHECK (
(central_api_key_enabled OR allow_user_api_key) AND
(
NOT allow_central_api_key_fallback OR
(central_api_key_enabled AND allow_user_api_key)
)
);
CREATE TABLE user_chat_provider_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chat_provider_id UUID NOT NULL REFERENCES chat_providers(id) ON DELETE CASCADE,
api_key TEXT NOT NULL CHECK (api_key != ''),
api_key_key_id TEXT REFERENCES dbcrypt_keys(active_key_digest),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, chat_provider_id)
);
@@ -1,3 +0,0 @@
ALTER TABLE user_secrets
DROP CONSTRAINT user_secrets_value_key_id_fkey,
DROP COLUMN value_key_id;
@@ -1,5 +0,0 @@
ALTER TABLE user_secrets
ADD COLUMN value_key_id TEXT;
ALTER TABLE ONLY user_secrets
ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest);
@@ -1,3 +0,0 @@
ALTER TABLE aibridge_token_usages
DROP COLUMN cache_read_input_tokens,
DROP COLUMN cache_write_input_tokens;
@@ -1,26 +0,0 @@
ALTER TABLE aibridge_token_usages
ADD COLUMN cache_read_input_tokens BIGINT NOT NULL DEFAULT 0,
ADD COLUMN cache_write_input_tokens BIGINT NOT NULL DEFAULT 0;
-- Backfill from metadata JSONB. Old rows stored cache tokens under
-- provider-specific keys; new rows use the dedicated columns above.
UPDATE aibridge_token_usages
SET
-- Cache-read metadata keys by provider:
-- Anthropic (/v1/messages): "cache_read_input"
-- OpenAI (/v1/responses): "input_cached"
-- OpenAI (/v1/chat/completions): "prompt_cached"
cache_read_input_tokens = GREATEST(
COALESCE((metadata->>'cache_read_input')::bigint, 0),
COALESCE((metadata->>'input_cached')::bigint, 0),
COALESCE((metadata->>'prompt_cached')::bigint, 0)
),
-- Cache-write metadata keys by provider:
-- Anthropic (/v1/messages): "cache_creation_input"
-- OpenAI does not report cache-write tokens.
cache_write_input_tokens = COALESCE((metadata->>'cache_creation_input')::bigint, 0)
WHERE metadata IS NOT NULL
AND cache_read_input_tokens = 0
AND cache_write_input_tokens = 0;
@@ -1,9 +0,0 @@
ALTER TABLE chats ADD COLUMN file_ids uuid[] DEFAULT '{}'::uuid[] NOT NULL;
UPDATE chats SET file_ids = (
SELECT COALESCE(array_agg(cfl.file_id), '{}')
FROM chat_file_links cfl
WHERE cfl.chat_id = chats.id
);
DROP TABLE chat_file_links;
@@ -1,17 +0,0 @@
CREATE TABLE chat_file_links (
chat_id uuid NOT NULL,
file_id uuid NOT NULL,
UNIQUE (chat_id, file_id)
);
CREATE INDEX idx_chat_file_links_chat_id ON chat_file_links (chat_id);
ALTER TABLE chat_file_links
ADD CONSTRAINT chat_file_links_chat_id_fkey
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE chat_file_links
ADD CONSTRAINT chat_file_links_file_id_fkey
FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE;
ALTER TABLE chats DROP COLUMN IF EXISTS file_ids;
@@ -0,0 +1,87 @@
INSERT INTO chat_automations (
id,
owner_id,
organization_id,
name,
description,
instructions,
model_config_id,
mcp_server_ids,
allowed_tools,
status,
max_chat_creates_per_hour,
max_messages_per_hour,
created_at,
updated_at
)
SELECT
'b3d0fd0e-8e1a-4f2c-9a3b-1234567890ab',
u.id,
o.id,
'fixture-automation',
'Fixture automation for migration testing.',
'You are a helpful assistant.',
NULL,
'{}',
'{}',
'active',
10,
60,
'2024-01-01 00:00:00+00',
'2024-01-01 00:00:00+00'
FROM users u
CROSS JOIN organizations o
ORDER BY u.created_at, u.id
LIMIT 1;
INSERT INTO chat_automation_triggers (
id,
automation_id,
type,
webhook_secret,
webhook_secret_key_id,
cron_schedule,
last_triggered_at,
filter,
label_paths,
created_at,
updated_at
) VALUES (
'c4e1fe1f-9f2b-4a3d-ab4c-234567890abc',
'b3d0fd0e-8e1a-4f2c-9a3b-1234567890ab',
'webhook',
'whsec_fixture_secret',
NULL,
NULL,
NULL,
'{"action": "opened"}'::jsonb,
'{"repo": "repository.full_name"}'::jsonb,
'2024-01-01 00:00:00+00',
'2024-01-01 00:00:00+00'
);
INSERT INTO chat_automation_events (
id,
automation_id,
trigger_id,
received_at,
payload,
filter_matched,
resolved_labels,
matched_chat_id,
created_chat_id,
status,
error
) VALUES (
'd5f20f20-a03c-4b4e-bc5d-345678901bcd',
'b3d0fd0e-8e1a-4f2c-9a3b-1234567890ab',
'c4e1fe1f-9f2b-4a3d-ab4c-234567890abc',
'2024-01-01 00:00:00+00',
'{"action": "opened", "repository": {"full_name": "coder/coder"}}'::jsonb,
TRUE,
'{"repo": "coder/coder"}'::jsonb,
NULL,
NULL,
'preview',
NULL
);
@@ -1,16 +0,0 @@
INSERT INTO user_chat_provider_keys (
user_id,
chat_provider_id,
api_key,
created_at,
updated_at
)
SELECT
id,
'0a8b2f84-b5a8-4c44-8c9f-e58c44a534a7',
'fixture-test-key',
'2025-01-01 00:00:00+00',
'2025-01-01 00:00:00+00'
FROM users
ORDER BY created_at, id
LIMIT 1;
@@ -1,5 +0,0 @@
INSERT INTO chat_file_links (chat_id, file_id)
VALUES (
'72c0438a-18eb-4688-ab80-e4c6a126ef96',
'00000000-0000-0000-0000-000000000099'
);
+6 -29
View File
@@ -10,7 +10,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/exp/maps"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
@@ -183,11 +182,14 @@ 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)
func (a ChatAutomation) RBACObject() rbac.Object {
return rbac.ResourceChatAutomation.
WithID(a.ID).
WithOwner(a.OwnerID.String()).
InOrg(a.OrganizationID)
}
func (c GetChatFileMetadataByChatIDRow) RBACObject() rbac.Object {
func (c ChatFile) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
}
@@ -928,28 +930,3 @@ func WorkspaceIdentityFromWorkspace(w Workspace) WorkspaceIdentity {
func (r GetWorkspaceAgentAndWorkspaceByIDRow) RBACObject() rbac.Object {
return r.WorkspaceTable.RBACObject()
}
// UpsertConnectionLogParams contains the parameters for upserting a
// connection log entry. This struct is hand-maintained (not generated
// by sqlc) because the single-row UpsertConnectionLog query was
// removed in favor of BatchUpsertConnectionLogs, but the struct is
// still used as the canonical connection log event type throughout
// the codebase.
type UpsertConnectionLogParams struct {
ID uuid.UUID `db:"id" json:"id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceName string `db:"workspace_name" json:"workspace_name"`
AgentName string `db:"agent_name" json:"agent_name"`
Type ConnectionType `db:"type" json:"type"`
Code sql.NullInt32 `db:"code" json:"code"`
IP pqtype.Inet `db:"ip" json:"ip"`
UserAgent sql.NullString `db:"user_agent" json:"user_agent"`
UserID uuid.NullUUID `db:"user_id" json:"user_id"`
SlugOrPort sql.NullString `db:"slug_or_port" json:"slug_or_port"`
ConnectionID uuid.NullUUID `db:"connection_id" json:"connection_id"`
DisconnectReason sql.NullString `db:"disconnect_reason" json:"disconnect_reason"`
Time time.Time `db:"time" json:"time"`
ConnectionStatus ConnectionStatus `db:"connection_status" json:"connection_status"`
}
+65 -7
View File
@@ -53,6 +53,7 @@ type customQuerier interface {
connectionLogQuerier
aibridgeQuerier
chatQuerier
chatAutomationQuerier
}
type templateQuerier interface {
@@ -584,7 +585,6 @@ func (q *sqlQuerier) CountAuthorizedAuditLogs(ctx context.Context, arg CountAudi
arg.DateTo,
arg.BuildReason,
arg.RequestID,
arg.CountCap,
)
if err != nil {
return 0, err
@@ -721,7 +721,6 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
arg.WorkspaceID,
arg.ConnectionID,
arg.Status,
arg.CountCap,
)
if err != nil {
return 0, err
@@ -798,7 +797,70 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.HasUnread); err != nil {
&i.Chat.AutomationID,
&i.HasUnread,
); 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
}
type chatAutomationQuerier interface {
GetAuthorizedChatAutomations(ctx context.Context, arg GetChatAutomationsParams, prepared rbac.PreparedAuthorized) ([]ChatAutomation, error)
}
func (q *sqlQuerier) GetAuthorizedChatAutomations(ctx context.Context, arg GetChatAutomationsParams, prepared rbac.PreparedAuthorized) ([]ChatAutomation, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.NoACLConverter(),
})
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(getChatAutomations, fmt.Sprintf(" AND %s", authorizedFilter))
if err != nil {
return nil, xerrors.Errorf("insert authorized filter: %w", err)
}
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedChatAutomations :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.OwnerID,
arg.OrganizationID,
arg.OffsetOpt,
arg.LimitOpt,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChatAutomation
for rows.Next() {
var i ChatAutomation
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.OrganizationID,
&i.Name,
&i.Description,
&i.Instructions,
&i.ModelConfigID,
pq.Array(&i.MCPServerIDs),
pq.Array(&i.AllowedTools),
&i.Status,
&i.MaxChatCreatesPerHour,
&i.MaxMessagesPerHour,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -867,7 +929,6 @@ 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,
@@ -1031,8 +1092,6 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
&i.Threads,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadInputTokens,
&i.CacheWriteInputTokens,
&i.LastPrompt,
); err != nil {
return nil, err
@@ -1130,7 +1189,6 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, a
&i.AIBridgeInterception.ThreadRootID,
&i.AIBridgeInterception.ClientSessionID,
&i.AIBridgeInterception.SessionID,
&i.AIBridgeInterception.ProviderName,
); err != nil {
return nil, err
}
@@ -145,13 +145,5 @@ func extractWhereClause(query string) string {
// Remove SQL comments
whereClause = regexp.MustCompile(`(?m)--.*$`).ReplaceAllString(whereClause, "")
// Normalize indentation so subquery wrapping doesn't cause
// mismatches.
lines := strings.Split(whereClause, "\n")
for i, line := range lines {
lines[i] = strings.TrimLeft(line, " \t")
}
whereClause = strings.Join(lines, "\n")
return strings.TrimSpace(whereClause)
}
+298 -44
View File
@@ -224,6 +224,11 @@ const (
ApiKeyScopeChatUpdate APIKeyScope = "chat:update"
ApiKeyScopeChatDelete APIKeyScope = "chat:delete"
ApiKeyScopeChat APIKeyScope = "chat:*"
ApiKeyScopeChatAutomationCreate APIKeyScope = "chat_automation:create"
ApiKeyScopeChatAutomationRead APIKeyScope = "chat_automation:read"
ApiKeyScopeChatAutomationUpdate APIKeyScope = "chat_automation:update"
ApiKeyScopeChatAutomationDelete APIKeyScope = "chat_automation:delete"
ApiKeyScopeChatAutomation APIKeyScope = "chat_automation:*"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -467,7 +472,12 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeChatRead,
ApiKeyScopeChatUpdate,
ApiKeyScopeChatDelete,
ApiKeyScopeChat:
ApiKeyScopeChat,
ApiKeyScopeChatAutomationCreate,
ApiKeyScopeChatAutomationRead,
ApiKeyScopeChatAutomationUpdate,
ApiKeyScopeChatAutomationDelete,
ApiKeyScopeChatAutomation:
return true
}
return false
@@ -680,6 +690,11 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeChatUpdate,
ApiKeyScopeChatDelete,
ApiKeyScopeChat,
ApiKeyScopeChatAutomationCreate,
ApiKeyScopeChatAutomationRead,
ApiKeyScopeChatAutomationUpdate,
ApiKeyScopeChatAutomationDelete,
ApiKeyScopeChatAutomation,
}
}
@@ -1107,6 +1122,198 @@ func AllBuildReasonValues() []BuildReason {
}
}
// Outcome of a chat automation event: filtered, preview, created, continued, rate_limited, or error.
type ChatAutomationEventStatus string
const (
ChatAutomationEventStatusFiltered ChatAutomationEventStatus = "filtered"
ChatAutomationEventStatusPreview ChatAutomationEventStatus = "preview"
ChatAutomationEventStatusCreated ChatAutomationEventStatus = "created"
ChatAutomationEventStatusContinued ChatAutomationEventStatus = "continued"
ChatAutomationEventStatusRateLimited ChatAutomationEventStatus = "rate_limited"
ChatAutomationEventStatusError ChatAutomationEventStatus = "error"
)
func (e *ChatAutomationEventStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ChatAutomationEventStatus(s)
case string:
*e = ChatAutomationEventStatus(s)
default:
return fmt.Errorf("unsupported scan type for ChatAutomationEventStatus: %T", src)
}
return nil
}
type NullChatAutomationEventStatus struct {
ChatAutomationEventStatus ChatAutomationEventStatus `json:"chat_automation_event_status"`
Valid bool `json:"valid"` // Valid is true if ChatAutomationEventStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullChatAutomationEventStatus) Scan(value interface{}) error {
if value == nil {
ns.ChatAutomationEventStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ChatAutomationEventStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullChatAutomationEventStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ChatAutomationEventStatus), nil
}
func (e ChatAutomationEventStatus) Valid() bool {
switch e {
case ChatAutomationEventStatusFiltered,
ChatAutomationEventStatusPreview,
ChatAutomationEventStatusCreated,
ChatAutomationEventStatusContinued,
ChatAutomationEventStatusRateLimited,
ChatAutomationEventStatusError:
return true
}
return false
}
func AllChatAutomationEventStatusValues() []ChatAutomationEventStatus {
return []ChatAutomationEventStatus{
ChatAutomationEventStatusFiltered,
ChatAutomationEventStatusPreview,
ChatAutomationEventStatusCreated,
ChatAutomationEventStatusContinued,
ChatAutomationEventStatusRateLimited,
ChatAutomationEventStatusError,
}
}
// Lifecycle state of a chat automation: disabled, preview, or active.
type ChatAutomationStatus string
const (
ChatAutomationStatusDisabled ChatAutomationStatus = "disabled"
ChatAutomationStatusPreview ChatAutomationStatus = "preview"
ChatAutomationStatusActive ChatAutomationStatus = "active"
)
func (e *ChatAutomationStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ChatAutomationStatus(s)
case string:
*e = ChatAutomationStatus(s)
default:
return fmt.Errorf("unsupported scan type for ChatAutomationStatus: %T", src)
}
return nil
}
type NullChatAutomationStatus struct {
ChatAutomationStatus ChatAutomationStatus `json:"chat_automation_status"`
Valid bool `json:"valid"` // Valid is true if ChatAutomationStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullChatAutomationStatus) Scan(value interface{}) error {
if value == nil {
ns.ChatAutomationStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ChatAutomationStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullChatAutomationStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ChatAutomationStatus), nil
}
func (e ChatAutomationStatus) Valid() bool {
switch e {
case ChatAutomationStatusDisabled,
ChatAutomationStatusPreview,
ChatAutomationStatusActive:
return true
}
return false
}
func AllChatAutomationStatusValues() []ChatAutomationStatus {
return []ChatAutomationStatus{
ChatAutomationStatusDisabled,
ChatAutomationStatusPreview,
ChatAutomationStatusActive,
}
}
// Discriminator for chat automation triggers: webhook or cron.
type ChatAutomationTriggerType string
const (
ChatAutomationTriggerTypeWebhook ChatAutomationTriggerType = "webhook"
ChatAutomationTriggerTypeCron ChatAutomationTriggerType = "cron"
)
func (e *ChatAutomationTriggerType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ChatAutomationTriggerType(s)
case string:
*e = ChatAutomationTriggerType(s)
default:
return fmt.Errorf("unsupported scan type for ChatAutomationTriggerType: %T", src)
}
return nil
}
type NullChatAutomationTriggerType struct {
ChatAutomationTriggerType ChatAutomationTriggerType `json:"chat_automation_trigger_type"`
Valid bool `json:"valid"` // Valid is true if ChatAutomationTriggerType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullChatAutomationTriggerType) Scan(value interface{}) error {
if value == nil {
ns.ChatAutomationTriggerType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ChatAutomationTriggerType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullChatAutomationTriggerType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ChatAutomationTriggerType), nil
}
func (e ChatAutomationTriggerType) Valid() bool {
switch e {
case ChatAutomationTriggerTypeWebhook,
ChatAutomationTriggerTypeCron:
return true
}
return false
}
func AllChatAutomationTriggerTypeValues() []ChatAutomationTriggerType {
return []ChatAutomationTriggerType{
ChatAutomationTriggerTypeWebhook,
ChatAutomationTriggerTypeCron,
}
}
type ChatMessageRole string
const (
@@ -4038,8 +4245,6 @@ 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
@@ -4055,13 +4260,11 @@ type AIBridgeTokenUsage struct {
ID uuid.UUID `db:"id" json:"id"`
InterceptionID uuid.UUID `db:"interception_id" json:"interception_id"`
// The ID for the response in which the tokens were used, produced by the provider.
ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"`
InputTokens int64 `db:"input_tokens" json:"input_tokens"`
OutputTokens int64 `db:"output_tokens" json:"output_tokens"`
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
CacheReadInputTokens int64 `db:"cache_read_input_tokens" json:"cache_read_input_tokens"`
CacheWriteInputTokens int64 `db:"cache_write_input_tokens" json:"cache_write_input_tokens"`
ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"`
InputTokens int64 `db:"input_tokens" json:"input_tokens"`
OutputTokens int64 `db:"output_tokens" json:"output_tokens"`
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// Audit log of tool calls in intercepted requests in AI Bridge
@@ -4180,6 +4383,76 @@ type Chat struct {
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"`
AutomationID uuid.NullUUID `db:"automation_id" json:"automation_id"`
}
// Chat automations bridge external events (webhooks, cron schedules) to Coder chats. A chat automation defines what to say, which model and tools to use, and how fast it is allowed to create or continue chats.
type ChatAutomation struct {
ID uuid.UUID `db:"id" json:"id"`
// The user on whose behalf chats are created. All RBAC checks and chat ownership are scoped to this user.
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
// Organization scope for RBAC. Combined with owner_id and name to form a unique constraint so automations are namespaced per user per org.
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
// The user-role message injected into every chat this automation creates. This is the core prompt that tells the LLM what to do.
Instructions string `db:"instructions" json:"instructions"`
// Optional model configuration override. When NULL the deployment default is used. SET NULL on delete so automations survive config changes gracefully.
ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"`
// MCP servers to attach to chats created by this automation. Stored as an array of UUIDs rather than a join table because the set is small and always read/written atomically.
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
// Tool allowlist. Empty means all tools available to the model config are permitted.
AllowedTools []string `db:"allowed_tools" json:"allowed_tools"`
// Lifecycle state: disabled — trigger events are silently dropped; preview — events are logged but no chat is created (dry-run); active — events create or continue chats.
Status ChatAutomationStatus `db:"status" json:"status"`
// Maximum number of new chats this automation may create in a rolling one-hour window. Prevents runaway webhook storms from flooding the system.
MaxChatCreatesPerHour int32 `db:"max_chat_creates_per_hour" json:"max_chat_creates_per_hour"`
// Maximum total messages (creates + continues) this automation may send in a rolling one-hour window. A second, broader throttle that catches high-frequency continuation patterns.
MaxMessagesPerHour int32 `db:"max_messages_per_hour" json:"max_messages_per_hour"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Every trigger invocation produces an event row regardless of outcome. This table is the audit trail and the data source for rate-limit window counts. Rows are append-only and expected to be purged by a background job after a retention period.
type ChatAutomationEvent struct {
ID uuid.UUID `db:"id" json:"id"`
AutomationID uuid.UUID `db:"automation_id" json:"automation_id"`
TriggerID uuid.NullUUID `db:"trigger_id" json:"trigger_id"`
ReceivedAt time.Time `db:"received_at" json:"received_at"`
// The raw payload that was evaluated. For webhooks this is the HTTP body; for cron triggers it is a synthetic JSON envelope with schedule metadata.
Payload json.RawMessage `db:"payload" json:"payload"`
// Whether the trigger filter conditions matched. False means the event was dropped before any chat interaction.
FilterMatched bool `db:"filter_matched" json:"filter_matched"`
// Labels resolved from the payload via label_paths. Stored so the event log shows exactly which labels were computed.
ResolvedLabels pqtype.NullRawMessage `db:"resolved_labels" json:"resolved_labels"`
// ID of an existing chat that was found via label matching and continued with a new message.
MatchedChatID uuid.NullUUID `db:"matched_chat_id" json:"matched_chat_id"`
// ID of a newly created chat (mutually exclusive with matched_chat_id in practice).
CreatedChatID uuid.NullUUID `db:"created_chat_id" json:"created_chat_id"`
// Outcome of the event: filtered — filter did not match; preview — automation is in preview mode; created — new chat was created; continued — existing chat was continued; rate_limited — rate limit prevented chat action; error — something went wrong.
Status ChatAutomationEventStatus `db:"status" json:"status"`
Error sql.NullString `db:"error" json:"error"`
}
// Triggers define how an automation is invoked. Each automation can have multiple triggers (e.g. one webhook + one cron schedule). Webhook and cron triggers share the same row shape with type-specific nullable columns to keep the schema simple.
type ChatAutomationTrigger struct {
ID uuid.UUID `db:"id" json:"id"`
AutomationID uuid.UUID `db:"automation_id" json:"automation_id"`
// Discriminator: webhook or cron. Determines which nullable columns are meaningful.
Type ChatAutomationTriggerType `db:"type" json:"type"`
// HMAC-SHA256 shared secret for webhook signature verification (X-Hub-Signature-256 header). NULL for cron triggers.
WebhookSecret sql.NullString `db:"webhook_secret" json:"webhook_secret"`
WebhookSecretKeyID sql.NullString `db:"webhook_secret_key_id" json:"webhook_secret_key_id"`
// Standard 5-field cron expression (minute hour dom month dow), with optional CRON_TZ= prefix. NULL for webhook triggers.
CronSchedule sql.NullString `db:"cron_schedule" json:"cron_schedule"`
// Timestamp of the last successful cron fire. The scheduler computes next = cron.Next(last_triggered_at) and fires when next <= now. NULL means the trigger has never fired. Not used for webhook triggers.
LastTriggeredAt sql.NullTime `db:"last_triggered_at" json:"last_triggered_at"`
// gjson path-to-value filter conditions evaluated against the incoming webhook payload. All conditions must match for the trigger to fire. NULL or empty means match everything.
Filter pqtype.NullRawMessage `db:"filter" json:"filter"`
// Maps chat label keys to gjson paths. When a trigger fires, labels are resolved from the payload and used to find an existing chat to continue (by label match) or set on a newly created chat.
LabelPaths pqtype.NullRawMessage `db:"label_paths" json:"label_paths"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type ChatDiffStatus struct {
@@ -4218,11 +4491,6 @@ type ChatFile struct {
Data []byte `db:"data" json:"data"`
}
type ChatFileLink struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
FileID uuid.UUID `db:"file_id" json:"file_id"`
}
type ChatMessage struct {
ID int64 `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
@@ -4271,15 +4539,12 @@ type ChatProvider struct {
DisplayName string `db:"display_name" json:"display_name"`
APIKey string `db:"api_key" json:"api_key"`
// The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted
ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"`
CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"`
Enabled bool `db:"enabled" json:"enabled"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
BaseUrl string `db:"base_url" json:"base_url"`
CentralApiKeyEnabled bool `db:"central_api_key_enabled" json:"central_api_key_enabled"`
AllowUserApiKey bool `db:"allow_user_api_key" json:"allow_user_api_key"`
AllowCentralApiKeyFallback bool `db:"allow_central_api_key_fallback" json:"allow_central_api_key_fallback"`
ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"`
CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"`
Enabled bool `db:"enabled" json:"enabled"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
BaseUrl string `db:"base_url" json:"base_url"`
}
type ChatQueuedMessage struct {
@@ -5232,16 +5497,6 @@ type User struct {
ChatSpendLimitMicros sql.NullInt64 `db:"chat_spend_limit_micros" json:"chat_spend_limit_micros"`
}
type UserChatProviderKey struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
ChatProviderID uuid.UUID `db:"chat_provider_id" json:"chat_provider_id"`
APIKey string `db:"api_key" json:"api_key"`
ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type UserConfig struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Key string `db:"key" json:"key"`
@@ -5271,16 +5526,15 @@ type UserLink struct {
}
type UserSecret struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Value string `db:"value" json:"value"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Value string `db:"value" json:"value"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Tracks the history of user status changes
+17 -27
View File
@@ -81,8 +81,8 @@ func newMsgQueue(ctx context.Context, l Listener, le ListenerWithErr) *msgQueue
}
func (q *msgQueue) run() {
var batch [maxDrainBatch]msgOrErr
for {
// wait until there is something on the queue or we are closed
q.cond.L.Lock()
for q.size == 0 && !q.closed {
q.cond.Wait()
@@ -91,32 +91,28 @@ func (q *msgQueue) run() {
q.cond.L.Unlock()
return
}
// Drain up to maxDrainBatch items while holding the lock.
n := min(q.size, maxDrainBatch)
for i := range n {
batch[i] = q.q[q.front]
q.front = (q.front + 1) % BufferSize
}
q.size -= n
item := q.q[q.front]
q.front = (q.front + 1) % BufferSize
q.size--
q.cond.L.Unlock()
// Dispatch each message individually without holding the lock.
for i := range n {
item := batch[i]
if item.err == nil {
if q.l != nil {
q.l(q.ctx, item.msg)
continue
}
if q.le != nil {
q.le(q.ctx, item.msg, nil)
continue
}
// process item without holding lock
if item.err == nil {
// real message
if q.l != nil {
q.l(q.ctx, item.msg)
continue
}
if q.le != nil {
q.le(q.ctx, nil, item.err)
q.le(q.ctx, item.msg, nil)
continue
}
// unhittable
continue
}
// if the listener wants errors, send it.
if q.le != nil {
q.le(q.ctx, nil, item.err)
}
}
}
@@ -237,12 +233,6 @@ type PGPubsub struct {
// for a subscriber before dropping messages.
const BufferSize = 2048
// maxDrainBatch is the maximum number of messages to drain from the ring
// buffer per iteration. Batching amortizes the cost of mutex
// acquire/release and cond.Wait across many messages, improving drain
// throughput during bursts.
const maxDrainBatch = 256
// Subscribe calls the listener when an event matching the name is received.
func (p *PGPubsub) Subscribe(event string, listener Listener) (cancel func(), err error) {
return p.subscribeQueue(event, newMsgQueue(context.Background(), listener, nil))
+43 -34
View File
@@ -65,7 +65,6 @@ type sqlcQuerier interface {
BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) error
BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg BatchUpdateWorkspaceLastUsedAtParams) error
BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg BatchUpdateWorkspaceNextStartAtParams) error
BatchUpsertConnectionLogs(ctx context.Context, arg BatchUpsertConnectionLogsParams) error
BulkMarkNotificationMessagesFailed(ctx context.Context, arg BulkMarkNotificationMessagesFailedParams) (int64, error)
BulkMarkNotificationMessagesSent(ctx context.Context, arg BulkMarkNotificationMessagesSentParams) (int64, error)
// Calculates the telemetry summary for a given provider, model, and client
@@ -75,10 +74,21 @@ type sqlcQuerier interface {
CleanTailnetCoordinators(ctx context.Context) error
CleanTailnetLostPeers(ctx context.Context) error
CleanTailnetTunnels(ctx context.Context) error
CleanupDeletedMCPServerIDsFromChatAutomations(ctx context.Context) error
CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error
CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error)
CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error)
CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error)
// Counts new-chat events in the rate-limit window. This count is
// approximate under concurrency: concurrent webhook handlers may
// each read the same count before any of them insert, so brief
// bursts can slightly exceed the configured cap.
CountChatAutomationChatCreatesInWindow(ctx context.Context, arg CountChatAutomationChatCreatesInWindowParams) (int64, error)
// Counts total message events (creates + continues) in the rate-limit
// window. This count is approximate under concurrency: concurrent
// webhook handlers may each read the same count before any of them
// insert, so brief bursts can slightly exceed the configured cap.
CountChatAutomationMessagesInWindow(ctx context.Context, arg CountChatAutomationMessagesInWindowParams) (int64, error)
CountConnectionLogs(ctx context.Context, arg CountConnectionLogsParams) (int64, error)
// Counts enabled, non-deleted model configs that lack both input and
// output pricing in their JSONB options.cost configuration.
@@ -101,6 +111,8 @@ type sqlcQuerier interface {
// be recreated.
DeleteAllWebpushSubscriptions(ctx context.Context) error
DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error
DeleteChatAutomationByID(ctx context.Context, id uuid.UUID) error
DeleteChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) error
DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error
DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error
DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error
@@ -151,8 +163,7 @@ type sqlcQuerier interface {
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error)
DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error
DeleteUserChatProviderKey(ctx context.Context, arg DeleteUserChatProviderKeyParams) error
DeleteUserSecretByUserIDAndName(ctx context.Context, arg DeleteUserSecretByUserIDAndNameParams) error
DeleteUserSecret(ctx context.Context, id uuid.UUID) error
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
@@ -199,6 +210,10 @@ type sqlcQuerier interface {
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
GetActiveAISeatCount(ctx context.Context) (int64, error)
// Returns all cron triggers whose parent automation is active or in
// preview mode. The scheduler uses this to evaluate which triggers
// are due.
GetActiveChatAutomationCronTriggers(ctx context.Context) ([]GetActiveChatAutomationCronTriggersRow, error)
GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error)
GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error)
GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error)
@@ -225,6 +240,11 @@ type sqlcQuerier interface {
// This function returns roles for authorization purposes. Implied member roles
// are included.
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
GetChatAutomationByID(ctx context.Context, id uuid.UUID) (ChatAutomation, error)
GetChatAutomationEventsByAutomationID(ctx context.Context, arg GetChatAutomationEventsByAutomationIDParams) ([]ChatAutomationEvent, error)
GetChatAutomationTriggerByID(ctx context.Context, id uuid.UUID) (ChatAutomationTrigger, error)
GetChatAutomationTriggersByAutomationID(ctx context.Context, automationID uuid.UUID) ([]ChatAutomationTrigger, error)
GetChatAutomations(ctx context.Context, arg GetChatAutomationsParams) ([]ChatAutomation, error)
GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error)
GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error)
// Per-root-chat cost breakdown for a single user within a date range.
@@ -244,10 +264,6 @@ type sqlcQuerier interface {
GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error)
GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error)
GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error)
// GetChatFileMetadataByChatID returns lightweight file metadata for
// all files linked to a chat. The data column is excluded to avoid
// loading file content.
GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]GetChatFileMetadataByChatIDRow, error)
GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error)
// GetChatIncludeDefaultSystemPrompt preserves the legacy default
// for deployments created before the explicit include-default toggle.
@@ -482,8 +498,8 @@ type sqlcQuerier interface {
// Used for recovery after coderd crashes or long hangs.
GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]Chat, error)
GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error)
GetTailnetTunnelPeerBindingsBatch(ctx context.Context, ids []uuid.UUID) ([]GetTailnetTunnelPeerBindingsBatchRow, error)
GetTailnetTunnelPeerIDsBatch(ctx context.Context, ids []uuid.UUID) ([]GetTailnetTunnelPeerIDsBatchRow, error)
GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error)
GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error)
GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error)
GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByOwnerIDAndNameParams) (Task, error)
GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error)
@@ -583,7 +599,6 @@ type sqlcQuerier interface {
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserChatCompactionThreshold(ctx context.Context, arg GetUserChatCompactionThresholdParams) (string, error)
GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) (string, error)
GetUserChatProviderKeys(ctx context.Context, userID uuid.UUID) ([]UserChatProviderKey, error)
GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error)
GetUserCount(ctx context.Context, includeSystem bool) (int64, error)
// Returns the minimum (most restrictive) group limit for a user.
@@ -598,6 +613,7 @@ type sqlcQuerier interface {
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error)
GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error)
GetUserSecret(ctx context.Context, id uuid.UUID) (UserSecret, error)
GetUserSecretByUserIDAndName(ctx context.Context, arg GetUserSecretByUserIDAndNameParams) (UserSecret, error)
// GetUserStatusCounts returns the count of users in each status over time.
// The time range is inclusively defined by the start_time and end_time parameters.
@@ -702,6 +718,9 @@ type sqlcQuerier interface {
InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error)
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error)
InsertChatAutomation(ctx context.Context, arg InsertChatAutomationParams) (ChatAutomation, error)
InsertChatAutomationEvent(ctx context.Context, arg InsertChatAutomationEventParams) (ChatAutomationEvent, error)
InsertChatAutomationTrigger(ctx context.Context, arg InsertChatAutomationTriggerParams) (ChatAutomationTrigger, error)
InsertChatFile(ctx context.Context, arg InsertChatFileParams) (InsertChatFileRow, error)
InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error)
InsertChatModelConfig(ctx context.Context, arg InsertChatModelConfigParams) (ChatModelConfig, error)
@@ -781,15 +800,6 @@ 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)
// LinkChatFiles inserts file associations into the chat_file_links
// join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
// is conditional: it only proceeds when the total number of links
// (existing + genuinely new) does not exceed max_file_links. Returns
// the number of genuinely new file IDs that were NOT inserted due to
// the cap. A return value of 0 means all files were linked (or were
// already linked). A positive value means the cap blocked that many
// new links.
LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, 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
@@ -817,13 +827,7 @@ type sqlcQuerier interface {
ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error)
ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error)
ListUserChatCompactionThresholds(ctx context.Context, userID uuid.UUID) ([]UserConfig, error)
// Returns metadata only (no value or value_key_id) for the
// REST API list and get endpoints.
ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]ListUserSecretsRow, error)
// Returns all columns including the secret value. Used by the
// provisioner (build-time injection) and the agent manifest
// (runtime injection).
ListUserSecretsWithValues(ctx context.Context, userID uuid.UUID) ([]UserSecret, error)
ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]UserSecret, error)
ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error)
MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error
OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error)
@@ -843,6 +847,10 @@ type sqlcQuerier interface {
// sequence, so this is acceptable.
PinChatByID(ctx context.Context, id uuid.UUID) error
PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error)
// Deletes old chat automation events in bounded batches to avoid
// long-running locks on high-volume tables. Callers should loop
// until zero rows are returned.
PurgeOldChatAutomationEvents(ctx context.Context, arg PurgeOldChatAutomationEventsParams) (int64, error)
ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error
RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error)
RemoveUserFromGroups(ctx context.Context, arg RemoveUserFromGroupsParams) ([]uuid.UUID, error)
@@ -873,13 +881,15 @@ type sqlcQuerier interface {
UnsetDefaultChatModelConfigs(ctx context.Context) error
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateChatAutomation(ctx context.Context, arg UpdateChatAutomationParams) (ChatAutomation, error)
UpdateChatAutomationTrigger(ctx context.Context, arg UpdateChatAutomationTriggerParams) (ChatAutomationTrigger, error)
UpdateChatAutomationTriggerLastTriggeredAt(ctx context.Context, arg UpdateChatAutomationTriggerLastTriggeredAtParams) error
UpdateChatAutomationTriggerWebhookSecret(ctx context.Context, arg UpdateChatAutomationTriggerWebhookSecretParams) (ChatAutomationTrigger, error)
UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error)
UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error)
// Bumps the heartbeat timestamp for the given set of chat IDs,
// provided they are still running and owned by the specified
// worker. Returns the IDs that were actually updated so the
// caller can detect stolen or completed chats via set-difference.
UpdateChatHeartbeats(ctx context.Context, arg UpdateChatHeartbeatsParams) ([]uuid.UUID, 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
@@ -950,7 +960,6 @@ type sqlcQuerier interface {
UpdateUsageEventsPostPublish(ctx context.Context, arg UpdateUsageEventsPostPublishParams) error
UpdateUserChatCompactionThreshold(ctx context.Context, arg UpdateUserChatCompactionThresholdParams) (UserConfig, error)
UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateUserChatCustomPromptParams) (UserConfig, error)
UpdateUserChatProviderKey(ctx context.Context, arg UpdateUserChatProviderKeyParams) (UserChatProviderKey, error)
UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error
UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error
UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error
@@ -962,7 +971,7 @@ type sqlcQuerier interface {
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error)
UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
@@ -1012,6 +1021,7 @@ type sqlcQuerier interface {
UpsertChatUsageLimitGroupOverride(ctx context.Context, arg UpsertChatUsageLimitGroupOverrideParams) (UpsertChatUsageLimitGroupOverrideRow, error)
UpsertChatUsageLimitUserOverride(ctx context.Context, arg UpsertChatUsageLimitUserOverrideParams) (UpsertChatUsageLimitUserOverrideRow, error)
UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error
UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error)
// The default proxy is implied and not actually stored in the database.
// So we need to store it's configuration here for display purposes.
// The functional values are immutable and controlled implicitly.
@@ -1038,7 +1048,6 @@ type sqlcQuerier interface {
// used to store the data, and the minutes are summed for each user and template
// combination. The result is stored in the template_usage_stats table.
UpsertTemplateUsageStats(ctx context.Context) error
UpsertUserChatProviderKey(ctx context.Context, arg UpsertUserChatProviderKeyParams) (UserChatProviderKey, error)
UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -11
View File
@@ -1,8 +1,8 @@
-- name: InsertAIBridgeInterception :one
INSERT INTO aibridge_interceptions (
id, api_key_id, initiator_id, provider, provider_name, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
id, api_key_id, initiator_id, provider, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
) VALUES (
@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
@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
)
RETURNING *;
@@ -31,9 +31,9 @@ WHERE aibridge_interceptions.id = (
-- name: InsertAIBridgeTokenUsage :one
INSERT INTO aibridge_token_usages (
id, interception_id, provider_response_id, input_tokens, output_tokens, cache_read_input_tokens, cache_write_input_tokens, metadata, created_at
id, interception_id, provider_response_id, input_tokens, output_tokens, metadata, created_at
) VALUES (
@id, @interception_id, @provider_response_id, @input_tokens, @output_tokens, @cache_read_input_tokens, @cache_write_input_tokens, COALESCE(@metadata::jsonb, '{}'::jsonb), @created_at
@id, @interception_id, @provider_response_id, @input_tokens, @output_tokens, COALESCE(@metadata::jsonb, '{}'::jsonb), @created_at
)
RETURNING *;
@@ -299,8 +299,21 @@ token_aggregates AS (
SELECT
COALESCE(SUM(tu.input_tokens), 0) AS token_count_input,
COALESCE(SUM(tu.output_tokens), 0) AS token_count_output,
COALESCE(SUM(tu.cache_read_input_tokens), 0) AS token_count_cached_read,
COALESCE(SUM(tu.cache_write_input_tokens), 0) AS token_count_cached_written,
-- Cached tokens are stored in metadata JSON, extract if available.
-- Read tokens may be stored in:
-- - cache_read_input (Anthropic)
-- - prompt_cached (OpenAI)
COALESCE(SUM(
COALESCE((tu.metadata->>'cache_read_input')::bigint, 0) +
COALESCE((tu.metadata->>'prompt_cached')::bigint, 0)
), 0) AS token_count_cached_read,
-- Written tokens may be stored in:
-- - cache_creation_input (Anthropic)
-- Note that cache_ephemeral_5m_input and cache_ephemeral_1h_input on
-- Anthropic are included in the cache_creation_input field.
COALESCE(SUM(
COALESCE((tu.metadata->>'cache_creation_input')::bigint, 0)
), 0) AS token_count_cached_written,
COUNT(tu.id) AS token_usages_count
FROM
interceptions_in_range i
@@ -539,8 +552,6 @@ SELECT
sp.threads,
COALESCE(st.input_tokens, 0)::bigint AS input_tokens,
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens,
COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens,
COALESCE(slp.prompt, '') AS last_prompt
FROM
session_page sp
@@ -562,9 +573,7 @@ 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,
COALESCE(SUM(tu.cache_read_input_tokens), 0)::bigint AS cache_read_input_tokens,
COALESCE(SUM(tu.cache_write_input_tokens), 0)::bigint AS cache_write_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
+88 -99
View File
@@ -149,105 +149,94 @@ VALUES (
RETURNING *;
-- name: CountAuditLogs :one
SELECT COUNT(*) FROM (
SELECT 1
FROM audit_logs
LEFT JOIN users ON audit_logs.user_id = users.id
LEFT JOIN organizations ON audit_logs.organization_id = organizations.id
-- First join on workspaces to get the initial workspace create
-- to workspace build 1 id. This is because the first create is
-- is a different audit log than subsequent starts.
LEFT JOIN workspaces ON audit_logs.resource_type = 'workspace'
AND audit_logs.resource_id = workspaces.id
-- Get the reason from the build if the resource type
-- is a workspace_build
LEFT JOIN workspace_builds wb_build ON audit_logs.resource_type = 'workspace_build'
AND audit_logs.resource_id = wb_build.id
-- Get the reason from the build #1 if this is the first
-- workspace create.
LEFT JOIN workspace_builds wb_workspace ON audit_logs.resource_type = 'workspace'
AND audit_logs.action = 'create'
AND workspaces.id = wb_workspace.workspace_id
AND wb_workspace.build_number = 1
WHERE
-- Filter resource_type
CASE
WHEN @resource_type::text != '' THEN resource_type = @resource_type::resource_type
ELSE true
END
-- Filter resource_id
AND CASE
WHEN @resource_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN resource_id = @resource_id
ELSE true
END
-- Filter organization_id
AND CASE
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.organization_id = @organization_id
ELSE true
END
-- Filter by resource_target
AND CASE
WHEN @resource_target::text != '' THEN resource_target = @resource_target
ELSE true
END
-- Filter action
AND CASE
WHEN @action::text != '' THEN action = @action::audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username::text != '' THEN user_id = (
SELECT id
FROM users
WHERE lower(username) = lower(@username)
AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @email::text != '' THEN users.email = @email
ELSE true
END
-- Filter by date_from
AND CASE
WHEN @date_from::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" >= @date_from
ELSE true
END
-- Filter by date_to
AND CASE
WHEN @date_to::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" <= @date_to
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN @build_reason::text != '' THEN COALESCE(wb_build.reason::text, wb_workspace.reason::text) = @build_reason
ELSE true
END
-- Filter request_id
AND CASE
WHEN @request_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.request_id = @request_id
ELSE true
END
-- Authorize Filter clause will be injected below in CountAuthorizedAuditLogs
-- @authorize_filter
-- Avoid a slow scan on a large table with joins. The caller
-- passes the count cap and we add 1 so the frontend can detect
-- capping and show "... of N+". A cap of 0 means no limit (NULLIF
-- -> NULL + 1 = NULL).
-- NOTE: Parameterizing this so that we can easily change from,
-- e.g., 2000 to 5000. However, use literal NULL (or no LIMIT)
-- here if disabling the capping on a large table permanently.
-- This way the PG planner can plan parallel execution for
-- potential large wins.
LIMIT NULLIF(@count_cap::int, 0) + 1
) AS limited_count;
SELECT COUNT(*)
FROM audit_logs
LEFT JOIN users ON audit_logs.user_id = users.id
LEFT JOIN organizations ON audit_logs.organization_id = organizations.id
-- First join on workspaces to get the initial workspace create
-- to workspace build 1 id. This is because the first create is
-- is a different audit log than subsequent starts.
LEFT JOIN workspaces ON audit_logs.resource_type = 'workspace'
AND audit_logs.resource_id = workspaces.id
-- Get the reason from the build if the resource type
-- is a workspace_build
LEFT JOIN workspace_builds wb_build ON audit_logs.resource_type = 'workspace_build'
AND audit_logs.resource_id = wb_build.id
-- Get the reason from the build #1 if this is the first
-- workspace create.
LEFT JOIN workspace_builds wb_workspace ON audit_logs.resource_type = 'workspace'
AND audit_logs.action = 'create'
AND workspaces.id = wb_workspace.workspace_id
AND wb_workspace.build_number = 1
WHERE
-- Filter resource_type
CASE
WHEN @resource_type::text != '' THEN resource_type = @resource_type::resource_type
ELSE true
END
-- Filter resource_id
AND CASE
WHEN @resource_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN resource_id = @resource_id
ELSE true
END
-- Filter organization_id
AND CASE
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.organization_id = @organization_id
ELSE true
END
-- Filter by resource_target
AND CASE
WHEN @resource_target::text != '' THEN resource_target = @resource_target
ELSE true
END
-- Filter action
AND CASE
WHEN @action::text != '' THEN action = @action::audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username::text != '' THEN user_id = (
SELECT id
FROM users
WHERE lower(username) = lower(@username)
AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @email::text != '' THEN users.email = @email
ELSE true
END
-- Filter by date_from
AND CASE
WHEN @date_from::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" >= @date_from
ELSE true
END
-- Filter by date_to
AND CASE
WHEN @date_to::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" <= @date_to
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN @build_reason::text != '' THEN COALESCE(wb_build.reason::text, wb_workspace.reason::text) = @build_reason
ELSE true
END
-- Filter request_id
AND CASE
WHEN @request_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.request_id = @request_id
ELSE true
END
-- Authorize Filter clause will be injected below in CountAuthorizedAuditLogs
-- @authorize_filter
;
-- name: DeleteOldAuditLogConnectionEvents :exec
DELETE FROM audit_logs
@@ -0,0 +1,80 @@
-- name: InsertChatAutomationEvent :one
INSERT INTO chat_automation_events (
id,
automation_id,
trigger_id,
received_at,
payload,
filter_matched,
resolved_labels,
matched_chat_id,
created_chat_id,
status,
error
) VALUES (
@id::uuid,
@automation_id::uuid,
sqlc.narg('trigger_id')::uuid,
@received_at::timestamptz,
@payload::jsonb,
@filter_matched::boolean,
sqlc.narg('resolved_labels')::jsonb,
sqlc.narg('matched_chat_id')::uuid,
sqlc.narg('created_chat_id')::uuid,
@status::chat_automation_event_status,
sqlc.narg('error')::text
) RETURNING *;
-- name: GetChatAutomationEventsByAutomationID :many
SELECT
*
FROM
chat_automation_events
WHERE
automation_id = @automation_id::uuid
AND CASE
WHEN sqlc.narg('status_filter')::chat_automation_event_status IS NOT NULL THEN status = sqlc.narg('status_filter')::chat_automation_event_status
ELSE true
END
ORDER BY
received_at DESC
OFFSET @offset_opt
LIMIT
COALESCE(NULLIF(@limit_opt :: int, 0), 50);
-- name: CountChatAutomationChatCreatesInWindow :one
-- Counts new-chat events in the rate-limit window. This count is
-- approximate under concurrency: concurrent webhook handlers may
-- each read the same count before any of them insert, so brief
-- bursts can slightly exceed the configured cap.
SELECT COUNT(*)
FROM chat_automation_events
WHERE automation_id = @automation_id::uuid
AND status = 'created'
AND received_at > @window_start::timestamptz;
-- name: CountChatAutomationMessagesInWindow :one
-- Counts total message events (creates + continues) in the rate-limit
-- window. This count is approximate under concurrency: concurrent
-- webhook handlers may each read the same count before any of them
-- insert, so brief bursts can slightly exceed the configured cap.
SELECT COUNT(*)
FROM chat_automation_events
WHERE automation_id = @automation_id::uuid
AND status IN ('created', 'continued')
AND received_at > @window_start::timestamptz;
-- name: PurgeOldChatAutomationEvents :execrows
-- Deletes old chat automation events in bounded batches to avoid
-- long-running locks on high-volume tables. Callers should loop
-- until zero rows are returned.
WITH old_events AS (
SELECT id
FROM chat_automation_events
WHERE received_at < @before::timestamptz
ORDER BY received_at ASC
LIMIT @limit_count
)
DELETE FROM chat_automation_events
USING old_events
WHERE chat_automation_events.id = old_events.id;
@@ -0,0 +1,85 @@
-- name: InsertChatAutomation :one
INSERT INTO chat_automations (
id,
owner_id,
organization_id,
name,
description,
instructions,
model_config_id,
mcp_server_ids,
allowed_tools,
status,
max_chat_creates_per_hour,
max_messages_per_hour,
created_at,
updated_at
) VALUES (
@id::uuid,
@owner_id::uuid,
@organization_id::uuid,
@name::text,
@description::text,
@instructions::text,
sqlc.narg('model_config_id')::uuid,
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
COALESCE(@allowed_tools::text[], '{}'::text[]),
@status::chat_automation_status,
@max_chat_creates_per_hour::integer,
@max_messages_per_hour::integer,
@created_at::timestamptz,
@updated_at::timestamptz
) RETURNING *;
-- name: GetChatAutomationByID :one
SELECT * FROM chat_automations WHERE id = @id::uuid;
-- name: GetChatAutomations :many
SELECT
*
FROM
chat_automations
WHERE
CASE
WHEN @owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN chat_automations.owner_id = @owner_id
ELSE true
END
AND CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN chat_automations.organization_id = @organization_id
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedChatAutomations
-- @authorize_filter
ORDER BY
created_at DESC, id DESC
OFFSET @offset_opt
LIMIT
COALESCE(NULLIF(@limit_opt :: int, 0), 50);
-- name: UpdateChatAutomation :one
UPDATE chat_automations SET
name = @name::text,
description = @description::text,
instructions = @instructions::text,
model_config_id = sqlc.narg('model_config_id')::uuid,
mcp_server_ids = COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
allowed_tools = COALESCE(@allowed_tools::text[], '{}'::text[]),
status = @status::chat_automation_status,
max_chat_creates_per_hour = @max_chat_creates_per_hour::integer,
max_messages_per_hour = @max_messages_per_hour::integer,
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid
RETURNING *;
-- name: DeleteChatAutomationByID :exec
DELETE FROM chat_automations WHERE id = @id::uuid;
-- name: CleanupDeletedMCPServerIDsFromChatAutomations :exec
UPDATE chat_automations
SET mcp_server_ids = (
SELECT COALESCE(array_agg(sid), '{}')
FROM unnest(chat_automations.mcp_server_ids) AS sid
WHERE sid IN (SELECT id FROM mcp_server_configs)
)
WHERE mcp_server_ids != '{}'
AND NOT (mcp_server_ids <@ COALESCE((SELECT array_agg(id) FROM mcp_server_configs), '{}'));
@@ -0,0 +1,87 @@
-- name: InsertChatAutomationTrigger :one
INSERT INTO chat_automation_triggers (
id,
automation_id,
type,
webhook_secret,
webhook_secret_key_id,
cron_schedule,
filter,
label_paths,
created_at,
updated_at
) VALUES (
@id::uuid,
@automation_id::uuid,
@type::chat_automation_trigger_type,
sqlc.narg('webhook_secret')::text,
sqlc.narg('webhook_secret_key_id')::text,
sqlc.narg('cron_schedule')::text,
sqlc.narg('filter')::jsonb,
sqlc.narg('label_paths')::jsonb,
@created_at::timestamptz,
@updated_at::timestamptz
) RETURNING *;
-- name: GetChatAutomationTriggerByID :one
SELECT * FROM chat_automation_triggers WHERE id = @id::uuid;
-- name: GetChatAutomationTriggersByAutomationID :many
SELECT * FROM chat_automation_triggers
WHERE automation_id = @automation_id::uuid
ORDER BY created_at ASC;
-- name: UpdateChatAutomationTrigger :one
UPDATE chat_automation_triggers SET
cron_schedule = COALESCE(sqlc.narg('cron_schedule'), cron_schedule),
filter = COALESCE(sqlc.narg('filter'), filter),
label_paths = COALESCE(sqlc.narg('label_paths'), label_paths),
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid
RETURNING *;
-- name: UpdateChatAutomationTriggerWebhookSecret :one
UPDATE chat_automation_triggers SET
webhook_secret = sqlc.narg('webhook_secret')::text,
webhook_secret_key_id = sqlc.narg('webhook_secret_key_id')::text,
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid
RETURNING *;
-- name: DeleteChatAutomationTriggerByID :exec
DELETE FROM chat_automation_triggers WHERE id = @id::uuid;
-- name: GetActiveChatAutomationCronTriggers :many
-- Returns all cron triggers whose parent automation is active or in
-- preview mode. The scheduler uses this to evaluate which triggers
-- are due.
SELECT
t.id,
t.automation_id,
t.type,
t.cron_schedule,
t.filter,
t.label_paths,
t.last_triggered_at,
t.created_at,
t.updated_at,
a.status AS automation_status,
a.owner_id AS automation_owner_id,
a.instructions AS automation_instructions,
a.name AS automation_name,
a.organization_id AS automation_organization_id,
a.model_config_id AS automation_model_config_id,
a.mcp_server_ids AS automation_mcp_server_ids,
a.allowed_tools AS automation_allowed_tools,
a.max_chat_creates_per_hour AS automation_max_chat_creates_per_hour,
a.max_messages_per_hour AS automation_max_messages_per_hour
FROM chat_automation_triggers t
JOIN chat_automations a ON a.id = t.automation_id
WHERE t.type = 'cron'
AND t.cron_schedule IS NOT NULL
AND a.status IN ('active', 'preview');
-- name: UpdateChatAutomationTriggerLastTriggeredAt :exec
UPDATE chat_automation_triggers
SET last_triggered_at = @last_triggered_at::timestamptz
WHERE id = @id::uuid;
-10
View File
@@ -8,13 +8,3 @@ SELECT * FROM chat_files WHERE id = @id::uuid;
-- name: GetChatFilesByIDs :many
SELECT * FROM chat_files WHERE id = ANY(@ids::uuid[]);
-- name: GetChatFileMetadataByChatID :many
-- GetChatFileMetadataByChatID returns lightweight file metadata for
-- all files linked to a chat. The data column is excluded to avoid
-- loading file content.
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
FROM chat_files cf
JOIN chat_file_links cfl ON cfl.file_id = cf.id
WHERE cfl.chat_id = @chat_id::uuid
ORDER BY cf.created_at ASC;
+2 -11
View File
@@ -40,10 +40,7 @@ INSERT INTO chat_providers (
base_url,
api_key_key_id,
created_by,
enabled,
central_api_key_enabled,
allow_user_api_key,
allow_central_api_key_fallback
enabled
) VALUES (
@provider::text,
@display_name::text,
@@ -51,10 +48,7 @@ INSERT INTO chat_providers (
@base_url::text,
sqlc.narg('api_key_key_id')::text,
sqlc.narg('created_by')::uuid,
@enabled::boolean,
@central_api_key_enabled::boolean,
@allow_user_api_key::boolean,
@allow_central_api_key_fallback::boolean
@enabled::boolean
)
RETURNING
*;
@@ -68,9 +62,6 @@ SET
base_url = @base_url::text,
api_key_key_id = sqlc.narg('api_key_key_id')::text,
enabled = @enabled::boolean,
central_api_key_enabled = @central_api_key_enabled::boolean,
allow_user_api_key = @allow_user_api_key::boolean,
allow_central_api_key_fallback = @allow_central_api_key_fallback::boolean,
updated_at = NOW()
WHERE
id = @id::uuid

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