Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cba2949c75 | |||
| cc143c8990 | |||
| 3def04a3ee | |||
| d4a9c63e91 | |||
| 00217fefa5 | |||
| fce05d0428 | |||
| 2ebc076b9e | |||
| e71dc6dd4d | |||
| ca3ae3643d | |||
| ed5c06f039 | |||
| 1221622bf0 | |||
| aa5ec0bfcc | |||
| 16add93908 | |||
| fe13fd065c | |||
| fb788530b3 | |||
| f4dc8f6b11 | |||
| bbeff0d4b5 | |||
| 78fa8094cc | |||
| a85e00eed0 | |||
| da50a34414 | |||
| cd784c755a | |||
| eb4860aac3 | |||
| 07fbe8ca7d | |||
| ba0a64d483 | |||
| 7757cd8e08 | |||
| fc1e0beb3b | |||
| 3a4a0b7270 | |||
| 11c1afb5e9 | |||
| be2e641162 | |||
| 83e2699914 | |||
| 515ba209fd | |||
| d15bfc2cb0 | |||
| 308053b0e4 | |||
| 7c29355e84 |
@@ -111,8 +111,8 @@ Tier 2 file filters:
|
||||
|
||||
- **Modernization Reviewer**: one instance per language present in the diff. Filter by extension:
|
||||
- Go: `*.go` — reference `.claude/docs/GO.md` before reviewing.
|
||||
- TypeScript: `*.ts` `*.tsx`
|
||||
- React: `*.tsx` `*.jsx`
|
||||
- TypeScript: `*.ts` `*.tsx`: reference `.agents/skills/deep-review/references/typescript.md` before reviewing.
|
||||
- React: `*.tsx` `*.jsx`: reference `.agents/skills/deep-review/references/react.md` before reviewing.
|
||||
|
||||
`.tsx` files match both TypeScript and React filters. Spawn both instances when the diff contains `.tsx` changes — TS covers language-level patterns; React covers component and hooks patterns. Before spawning, verify each instance's filter produces a non-empty diff. Skip instances whose filtered diff is empty.
|
||||
|
||||
@@ -155,9 +155,11 @@ File scope: {filter from step 2}.
|
||||
Output file: {REVIEW_DIR}/{role-name}.md
|
||||
```
|
||||
|
||||
For the Modernization Reviewer (Go), add after the methodology line:
|
||||
For Modernization Reviewer instances, add the language reference after the methodology line:
|
||||
|
||||
> Read `.claude/docs/GO.md` as your Go language reference before reviewing.
|
||||
- **Go:** `Read .claude/docs/GO.md as your Go language reference before reviewing.`
|
||||
- **TypeScript:** `Read .agents/skills/deep-review/references/typescript.md as your TypeScript language reference before reviewing.`
|
||||
- **React:** `Read .agents/skills/deep-review/references/react.md as your React language reference before reviewing.`
|
||||
|
||||
For re-reviews, append to both Tier 1 and Tier 2 prompts:
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# Modern React (18–19.2) + Compiler 1.0 — Reference
|
||||
|
||||
Reference for writing idiomatic React. Covers what changed, what it replaced, and what to reach for. Includes React Compiler patterns — what the compiler handles automatically, what it changes semantically, and how to verify its behavior empirically. Scope: client-side SPA patterns only. Server Components, `use server`, and `use client` directives are framework-specific and omitted. Check the project's React version and compiler config before reaching for newer APIs.
|
||||
|
||||
## How modern React thinks differently
|
||||
|
||||
**Concurrent rendering** (18): React can now pause, interrupt, and resume renders. This is the foundation everything else builds on. Most existing code "just works," but components that produce side effects during render (mutations, subscriptions, network calls in the render body) are unsafe and will misbehave. Concurrent features are opt-in — they only activate when you use a concurrent API like `startTransition` or `useDeferredValue`.
|
||||
|
||||
**Urgent vs. non-urgent updates** (18): The `startTransition` / `useTransition` API introduces a formal split between updates that must feel immediate (typing, clicking) and updates that can be interrupted (filtering a large list, navigating to a new screen). Non-urgent updates yield to urgent ones mid-render. Use this instead of `setTimeout` or manual debounce when you want the UI to stay responsive during expensive re-renders.
|
||||
|
||||
**Actions** (19): Async functions passed to `startTransition` are called "Actions." They automatically manage pending state, error handling, and optimistic updates as a unit. The `useActionState` hook and `<form action={fn}>` prop are built on this. The pattern replaces the hand-rolled `isPending/setIsPending` + `try/catch` + `setError` boilerplate that was previously necessary for every data mutation.
|
||||
|
||||
**Automatic batching** (18): State updates are now batched everywhere — inside `setTimeout`, `Promise.then`, native event handlers, etc. Previously batching only happened inside React-managed event handlers. If you genuinely need a synchronous flush, use `flushSync`.
|
||||
|
||||
**Automatic memoization** (Compiler 1.0): React Compiler is a build-time Babel plugin that automatically inserts memoization into components and hooks. It replaces manual `useMemo`, `useCallback`, and `React.memo` — including conditional memoization and memoization after early returns, which manual APIs cannot express. The compiler only processes components and hooks, not standalone functions. It understands data flow and mutability through its own HIR (High-level Intermediate Representation), so it can memoize more granularly than a human would. Projects adopt it incrementally — typically via path-based Babel overrides or the `"use memo"` directive. Components that violate the Rules of React are silently skipped (no build error), so the automated lint tools that check compiler compatibility matter.
|
||||
|
||||
## Replace these patterns
|
||||
|
||||
The left column reflects patterns common before React 18/19. Write the right column instead. The "Since" column tells you the minimum React version required.
|
||||
|
||||
| Old pattern | Modern replacement | Since |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- |
|
||||
| `ReactDOM.render(<App />, el)` | `createRoot(el).render(<App />)` | 18 |
|
||||
| `ReactDOM.hydrate(<App />, el)` | `hydrateRoot(el, <App />)` | 18 |
|
||||
| `ReactDOM.unmountComponentAtNode(el)` | `root.unmount()` | 18 |
|
||||
| `ReactDOM.findDOMNode(this)` | DOM ref: `const ref = useRef(); ref.current` | 18 |
|
||||
| `<Context.Provider value={v}>` | `<Context value={v}>` | 19 |
|
||||
| `React.forwardRef((props, ref) => ...)` | `function Comp({ ref, ...props }) { ... }` (ref as a regular prop) | 19 |
|
||||
| String ref `ref="input"` in class components | Callback ref or `createRef()` | 19 |
|
||||
| `Heading.propTypes = { ... }` | TypeScript / ES6 type annotations | 19 |
|
||||
| `Component.defaultProps = { ... }` on function components | ES6 default parameters `({ text = 'Hi' })` | 19 |
|
||||
| Legacy Context: `contextTypes` + `getChildContext` | `React.createContext()` + `contextType` | 19 |
|
||||
| `import { act } from 'react-dom/test-utils'` | `import { act } from 'react'` | 19 |
|
||||
| `import ShallowRenderer from 'react-test-renderer/shallow'` | `import ShallowRenderer from 'react-shallow-renderer'` | 19 |
|
||||
| Manual `isPending` state around async calls | `const [isPending, startTransition] = useTransition()` | 18 |
|
||||
| Manual optimistic state + revert logic | `useOptimistic(currentValue)` | 19 |
|
||||
| `useEffect` to subscribe to external stores | `useSyncExternalStore(subscribe, getSnapshot)` | 18 |
|
||||
| Hand-rolled unique ID (counter, random, index) | `useId()` — SSR-safe, hydration-safe | 18 |
|
||||
| `useEffect` to inject `<title>` or `<meta>` / `react-helmet` | Render `<title>`, `<meta>`, `<link>` directly in components; React hoists them | 19 |
|
||||
| `ReactDOM.useFormState(action, initial)` (Canary name) | `useActionState(action, initial)` | 19 |
|
||||
| `useReducer<React.Reducer<State, Action>>(reducer)` | `useReducer(reducer)` — infers from the reducer function | 19 |
|
||||
| `<div ref={current => (instance = current)} />` (implicit return) | `<div ref={current => { instance = current }} />` (explicit block body) | 19 |
|
||||
| `useRef<T>()` with no argument | `useRef<T>(undefined)` or `useRef<T \| null>(null)` — argument is now required | 19 |
|
||||
| `MutableRefObject<T>` type annotation | `RefObject<T>` — all refs are mutable now; `MutableRefObject` is deprecated | 19 |
|
||||
| `React.createFactory('button')` | `<button />` JSX | 19 |
|
||||
| `useMemo(() => expr, [deps])` in compiled components | `const val = expr;` — compiler memoizes automatically | C 1.0 |
|
||||
| `useCallback(fn, [deps])` in compiled components | `const fn = () => { ... };` — compiler memoizes automatically | C 1.0 |
|
||||
| `React.memo(Component)` in compiled components | Plain component — compiler skips re-render when props are unchanged | C 1.0 |
|
||||
| `eslint-plugin-react-compiler` (standalone) | `eslint-plugin-react-hooks@latest` (compiler rules merged into recommended) | C 1.0 |
|
||||
| `useRef` + `useLayoutEffect` for stable callbacks | `useEffectEvent(fn)` — compiler handles both, but `useEffectEvent` is clearer | 19.2 |
|
||||
|
||||
## New capabilities
|
||||
|
||||
These enable things that weren't practical before. Reach for them in the described situations.
|
||||
|
||||
| What | Since | When to use it |
|
||||
| -------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `useTransition()` / `startTransition()` | 18 | Mark a state update as non-urgent so React can interrupt it to handle clicks or keystrokes. The `isPending` boolean lets you show a loading indicator without blocking the UI. |
|
||||
| `useDeferredValue(value, initialValue?)` | 18 / 19 | Defer re-rendering a slow subtree: pass the deferred value as a prop, wrap the expensive child in `memo`. Unlike debounce, uses no fixed timeout — renders as soon as the browser is idle. The `initialValue` arg (19) avoids a flash on first render. |
|
||||
| `useId()` | 18 | Generate a stable, SSR-consistent ID for accessibility attributes (`htmlFor`, `aria-describedby`). Do not use for list keys. |
|
||||
| `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)` | 18 | Subscribe to external (non-React) state stores safely under concurrent rendering. Preferred over `useEffect`-based subscriptions in libraries. |
|
||||
| `useActionState(action, initialState)` | 19 | Manage an async mutation: returns `[state, wrappedAction, isPending]`. Handles pending, result, and error state as a unit. Replaces the manual `isPending` + `try/catch` + `setError` pattern. |
|
||||
| `useOptimistic(currentValue)` | 19 | Show a speculative value while an async Action is in flight. Returns `[optimisticValue, setOptimistic]`. React automatically reverts to `currentValue` when the transition settles. |
|
||||
| `use(promiseOrContext)` | 19 | Read a promise or Context value inside a component or custom hook. Unlike hooks, `use` can be called conditionally (after early returns). Promises must come from a cache — do not create them during render. |
|
||||
| `useFormStatus()` (from `react-dom`) | 19 | Read `{ pending, data, method, action }` of the nearest parent `<form>` Action. Works across component boundaries without prop drilling — useful for submit buttons inside design-system components. |
|
||||
| `useEffectEvent(fn)` | 19.2 | Extract a non-reactive callback from an effect. The function sees the latest props/state without being listed in deps, and is never stale. Replaces the `useRef`-and-mutate-in-layout-effect workaround for stable event-like callbacks. The compiler has built-in knowledge of this hook and correctly prunes its return value from effect dependency arrays. Both `useEffectEvent` and the old ref workaround compile cleanly; `useEffectEvent` is preferred for clarity. |
|
||||
| `<Activity>` | 19.2 | Hide part of the UI while preserving its state and DOM. React deprioritizes updates to hidden content. Use via framework APIs for route prerendering or tab preservation — not a direct replacement for CSS `visibility`. |
|
||||
| `captureOwnerStack()` | 19.1 | Dev-only API that returns a string showing which components are responsible for rendering the current component (owner stack, not call stack). Useful for custom error overlays. Returns `null` in production. |
|
||||
| `<form action={fn}>` | 19 | Pass an async function as a form's `action` prop. React handles submission, pending state, and automatic form reset on success. Works with `useActionState` and `useFormStatus`. |
|
||||
| Ref cleanup function | 19 | Return a cleanup function from a ref callback: `ref={el => { ...; return () => cleanup(); }}`. React calls it on unmount. Replaces the pattern of checking `el === null` in the callback. |
|
||||
| `<link rel="stylesheet" precedence="default">` | 19 | Declare a stylesheet next to the component that needs it. React deduplicates and inserts it in the correct order before revealing Suspense content. |
|
||||
| `preinit`, `preload`, `prefetchDNS`, `preconnect` (from `react-dom`) | 19 | Imperatively hint the browser to load resources early. Call from render or event handlers. React deduplicates hints across the component tree. |
|
||||
| React Compiler (`babel-plugin-react-compiler`) | C 1.0 | Build-time automatic memoization for components and hooks. Install, add to Babel/Vite pipeline. Projects typically start with path-based overrides to compile a subset of files. |
|
||||
| `"use memo"` directive | C 1.0 | Opt a single function into compilation when using `compilationMode: 'annotation'`. Place at the start of the function body. Module-level `"use memo"` at the top of a file compiles all functions in that file. |
|
||||
| `"use no memo"` directive | C 1.0 | Temporary escape hatch — skip compilation for a specific component or hook that causes a runtime regression. Not a permanent solution. Place at the start of the function body. |
|
||||
| Compiler-powered ESLint rules | C 1.0 | Rules for purity, refs, set-state-in-render, immutability, etc. now ship in `eslint-plugin-react-hooks` recommended preset. Surface Rules-of-React violations even without the compiler installed. Note: some projects use Biome instead — check project lint config. |
|
||||
|
||||
## Key APIs
|
||||
|
||||
### `useTransition` and `startTransition` (18)
|
||||
|
||||
`useTransition` returns `[isPending, startTransition]`. Wrap any state update that is not directly tied to the user's current gesture inside `startTransition`. React will render the old UI while computing the new one, and `isPending` is `true` during that window.
|
||||
|
||||
In React 19, `startTransition` can accept an async function (an "Action"). React sets `isPending` to `true` for the entire duration of the async work, not just during the synchronous part.
|
||||
|
||||
```tsx
|
||||
// 18: synchronous transition
|
||||
const [isPending, startTransition] = useTransition();
|
||||
startTransition(() => setQuery(input));
|
||||
|
||||
// 19: async Action — isPending stays true until the await settles
|
||||
startTransition(async () => {
|
||||
const err = await updateName(name);
|
||||
if (err) setError(err);
|
||||
});
|
||||
```
|
||||
|
||||
Use `startTransition` (the module-level export) when you cannot use the hook (outside a component, in a router callback, etc.).
|
||||
|
||||
### `useDeferredValue` (18 / 19)
|
||||
|
||||
Creates a "lagging" copy of a value. Pass it to a memoized, expensive component so that React can render the stale UI while computing the updated one.
|
||||
|
||||
```tsx
|
||||
// 19: initialValue shows '' on first render; avoids loading flash
|
||||
const deferred = useDeferredValue(searchQuery, "");
|
||||
return <Results query={deferred} />; // Results wrapped in memo
|
||||
```
|
||||
|
||||
`deferred !== searchQuery` while the deferred render is in progress — use this to show a "stale" indicator.
|
||||
|
||||
### `useActionState` (19)
|
||||
|
||||
Replaces the `useState` + `isPending` + `try/catch` + `setError` boilerplate for any async operation that can be retried or submitted as a form.
|
||||
|
||||
```tsx
|
||||
const [error, submitAction, isPending] = useActionState(
|
||||
async (prevState, formData) => {
|
||||
const err = await updateName(formData.get("name"));
|
||||
if (err) return err; // returned value becomes next state
|
||||
redirect("/profile");
|
||||
return null;
|
||||
},
|
||||
null, // initialState
|
||||
);
|
||||
|
||||
// Use submitAction as the form's action prop or call it directly
|
||||
<form action={submitAction}>
|
||||
<input name="name" />
|
||||
<button disabled={isPending}>Save</button>
|
||||
{error && <p>{error}</p>}
|
||||
</form>;
|
||||
```
|
||||
|
||||
### `useOptimistic` (19)
|
||||
|
||||
Shows a speculative value immediately while an async Action is in progress. React automatically reverts to the server-confirmed value when the Action resolves or rejects.
|
||||
|
||||
```tsx
|
||||
const [optimisticName, setOptimisticName] = useOptimistic(currentName);
|
||||
|
||||
const submit = async (formData) => {
|
||||
const newName = formData.get("name");
|
||||
setOptimisticName(newName); // shows immediately
|
||||
await updateName(newName); // reverts if this throws
|
||||
};
|
||||
```
|
||||
|
||||
### `use()` (19)
|
||||
|
||||
Unlike hooks, `use` can appear after conditional statements. Two primary uses:
|
||||
|
||||
**Reading a promise** (must be stable — from a cache, not created inline):
|
||||
|
||||
```tsx
|
||||
function Comments({ commentsPromise }) {
|
||||
const comments = use(commentsPromise); // suspends until resolved
|
||||
return comments.map((c) => <p key={c.id}>{c.text}</p>);
|
||||
}
|
||||
```
|
||||
|
||||
**Reading context after an early return** (hooks cannot appear after `return`):
|
||||
|
||||
```tsx
|
||||
function Heading({ children }) {
|
||||
if (!children) return null;
|
||||
const theme = use(ThemeContext); // valid here; hooks would not be
|
||||
return <h1 style={{ color: theme.color }}>{children}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
### `useSyncExternalStore` (18)
|
||||
|
||||
The correct way for libraries (and app code) to subscribe to non-React state. Prevents tearing under concurrent rendering.
|
||||
|
||||
```tsx
|
||||
const value = useSyncExternalStore(
|
||||
store.subscribe, // called when store changes
|
||||
store.getSnapshot, // returns current value (must be stable reference if unchanged)
|
||||
store.getServerSnapshot, // optional: for SSR
|
||||
);
|
||||
```
|
||||
|
||||
## Verifying compiler behavior
|
||||
|
||||
The compiler is a black box unless you inspect its output. When reviewing code in compiled paths, run the compiler on the specific code to see what it actually does. Do not guess — verify.
|
||||
|
||||
**Run the compiler on a code snippet:**
|
||||
|
||||
```sh
|
||||
cd site && node -e "
|
||||
const {transformSync} = require('@babel/core');
|
||||
const code = \`<paste component here>\`;
|
||||
const diagnostics = [];
|
||||
const result = transformSync(code, {
|
||||
plugins: [
|
||||
['@babel/plugin-syntax-typescript', {isTSX: true}],
|
||||
['babel-plugin-react-compiler', {
|
||||
logger: {
|
||||
logEvent(_, event) {
|
||||
if (event.kind === 'CompileError' || event.kind === 'CompileSkip') {
|
||||
diagnostics.push(event.detail?.toString?.()?.substring(0, 200));
|
||||
}
|
||||
},
|
||||
},
|
||||
}],
|
||||
],
|
||||
filename: 'test.tsx',
|
||||
});
|
||||
console.log('Compiled:', result.code.includes('_c('));
|
||||
if (diagnostics.length) console.log('Diagnostics:', diagnostics);
|
||||
console.log(result.code);
|
||||
"
|
||||
```
|
||||
|
||||
**Reading compiled output:**
|
||||
|
||||
- `const $ = _c(N)` — allocates N memoization cache slots.
|
||||
- `if ($[n] !== dep)` — cache invalidation guard. Re-computes when `dep` changes (referential equality).
|
||||
- `if ($[n] === Symbol.for("react.memo_cache_sentinel"))` — one-time initialization. Runs once on first render, cached forever after. This is how the compiler handles expressions with no reactive dependencies.
|
||||
- `_temp` functions — pure callbacks the compiler hoisted out of the component body.
|
||||
|
||||
**Check all compiled files at once:**
|
||||
|
||||
```sh
|
||||
cd site && pnpm run lint:compiler
|
||||
```
|
||||
|
||||
This runs the compiler on every file in the compiled paths and reports CompileError / CompileSkip diagnostics. Zero diagnostics means all functions compiled cleanly.
|
||||
|
||||
**What the compiler catches vs. what it does not:**
|
||||
|
||||
The compiler emits `CompileError` for mutations of props, state, or hook arguments during render, and for `ref.current` access during render. The project's lint pipeline catches these automatically — do not flag them in review.
|
||||
|
||||
The compiler does **not** flag impure function calls during render (`Math.random()`, `Date.now()`, `new Date()`). Instead it silently memoizes them with a sentinel guard, freezing the value after first render. This changes semantics without any diagnostic. Verify suspicious calls by running the compiler and checking for sentinel guards in the output.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
Things that are easy to get wrong even when you know the modern API exists. Check your output against these.
|
||||
|
||||
**Effects run twice in development with StrictMode.** React 18 intentionally mounts → unmounts → remounts every component in dev to surface effects that are not resilient to remounting. This is not a bug. If an effect breaks on the second mount, it is missing a cleanup function. Write `return () => cleanup()` from every effect that sets up a subscription, timer, or external resource.
|
||||
|
||||
**Concurrent rendering can call render multiple times.** The render function (component body) may be called more than once before React commits to the DOM. Side effects (mutations, subscriptions, logging) in the render body will run multiple times. Move them into `useEffect` or event handlers.
|
||||
|
||||
**Do not create promises during render and pass them to `use()`.** A new promise is created every render, causing an infinite suspend-retry loop. Create the promise outside the component (module level), or use a caching library (SWR, React Query, `cache()` from React) to stabilize it.
|
||||
|
||||
**`useOptimistic` reverts automatically — do not fight it.** The optimistic value is a presentation layer only. When the Action settles, React replaces it with the real `currentValue` you passed in. Do not try to sync optimistic state back to your real state; let React handle the revert.
|
||||
|
||||
**`flushSync` opts out of automatic batching.** If third-party code or a browser API (e.g. `ResizeObserver`) calls `setState` and you need synchronous DOM flushing, wrap with `flushSync(() => setState(...))`. This is a last resort; prefer letting React batch.
|
||||
|
||||
**`forwardRef` still works in React 19 but will be deprecated.** Function components accept `ref` as a plain prop now. New code should use the prop directly. Existing `forwardRef` wrappers continue to work without changes; migrate when convenient.
|
||||
|
||||
**`<Activity>` does not unmount.** Content inside a hidden `<Activity>` boundary stays mounted. Effects keep running. Use it for preserving scroll position or form state, not for preventing expensive mounts — use lazy loading for that.
|
||||
|
||||
**TypeScript: implicit returns from ref callbacks are now type errors.** In React 19, returning anything other than a cleanup function (or nothing) from a ref callback is rejected by the TypeScript types. The most common case is arrow-function refs that implicitly return the DOM node:
|
||||
|
||||
```tsx
|
||||
// Error in React 19 types:
|
||||
<div ref={el => (instance = el)} />
|
||||
|
||||
// Fix — use a block body:
|
||||
<div ref={el => { instance = el; }} />
|
||||
```
|
||||
|
||||
**TypeScript: `useRef` now requires an argument.** `useRef<T>()` with no argument is a type error. Pass `undefined` for mutable refs or `null` for DOM refs you initialize on mount: `useRef<T>(undefined)` / `useRef<HTMLDivElement | null>(null)`.
|
||||
|
||||
**`useId` output format changed across versions.** React 18 produced `:r0:`. React 19.1 changed it to `«r0»`. React 19.2 changed it again to `_r0`. Do not parse or depend on the specific format — treat it as an opaque string.
|
||||
|
||||
**`useFormStatus` reads the nearest parent `<form>` with a function `action`.** It does not reflect native HTML form submissions — only React Actions. A submit button that is a sibling of `<form>` (rather than a descendant) will not see the form's status.
|
||||
|
||||
**Context as a provider (`<Context>`) requires React 19; `<Context.Provider>` still works.** Do not use `<Context>` shorthand in a codebase that needs to support React 18. The two forms can coexist during migration.
|
||||
|
||||
**Compiler freezes impure expressions silently.** `Math.random()`, `Date.now()`, `new Date()`, and `window.innerWidth` in a component body all compile without diagnostics. The compiler wraps them in a sentinel guard (`Symbol.for("react.memo_cache_sentinel")`) that runs the expression once and caches the result forever. The value never updates on re-render. Fix: move to a `useState` initializer (`useState(() => Math.random())`), `useEffect`, or event handler.
|
||||
|
||||
**Component granularity affects compiler optimization.** When one pattern in a component causes a `CompileError` (e.g., a necessary `ref.current` read during render), the compiler skips the **entire** component. If the rest of the component would benefit from compilation, extract the non-compilable pattern into a small child component. This keeps the parent compiled.
|
||||
|
||||
**The compiler only memoizes components and hooks.** Standalone utility functions (even expensive ones called during render) are not compiled. If a utility function is truly expensive, it still needs its own caching strategy outside of React (e.g., a module-level cache, `WeakMap`, etc.).
|
||||
|
||||
**Changing memoization can shift `useEffect` firing.** A value that was unstable before compilation may become stable after, causing an effect that depended on it to fire less often. Conversely, future compiler changes may alter memoization granularity. Effects that use memoized values as dependencies should be resilient to these changes — they should be true synchronization effects, not "run this when X changes" hacks.
|
||||
|
||||
## Behavioral changes that affect code
|
||||
|
||||
- **Automatic batching** (18): State updates in `setTimeout`, `Promise.then`, `addEventListener` callbacks, etc. are now batched into a single re-render. Previously only React synthetic event handlers were batched. Code that relied on unbatched updates (reading DOM synchronously after each `setState`) must use `flushSync`.
|
||||
|
||||
- **StrictMode double-invoke** (18): In development, every component is mounted → unmounted → remounted with the previous state. Every effect runs cleanup → setup twice on initial mount. `useMemo` and `useCallback` also double-invoke their functions. Production behavior is unchanged. If a test or component breaks under this, the component had a latent cleanup bug.
|
||||
|
||||
- **StrictMode ref double-invoke** (19): In development, ref callbacks are also invoked twice on mount (attach → detach → attach). Return a cleanup function from the ref callback to handle detach correctly.
|
||||
|
||||
- **StrictMode memoization reuse** (19): During the second pass of double-rendering, `useMemo` and `useCallback` now reuse the cached result from the first pass instead of calling the function again. Components that are already StrictMode-compatible should not notice a difference.
|
||||
|
||||
- **Suspense fallback commits immediately** (19): When a component suspends, React now commits the nearest `<Suspense>` fallback without waiting for sibling trees to finish rendering. After the fallback is shown, React "pre-warms" suspended siblings in the background. This makes fallbacks appear faster but changes the order of rendering work.
|
||||
|
||||
- **Error re-throwing removed** (19): Errors that are not caught by an Error Boundary are now reported to `window.reportError` (not re-thrown). Errors caught by an Error Boundary go to `console.error` once. If your production monitoring relied on the re-thrown error, add handlers to `createRoot`: `createRoot(el, { onUncaughtError, onCaughtError })`.
|
||||
|
||||
- **Transitions in `popstate` are synchronous** (19): Browser back/forward navigation triggers synchronous transition flushing. This ensures the URL and UI update together atomically during history navigation.
|
||||
|
||||
- **`useEffect` from discrete events flushes synchronously** (18): Effects triggered by a click or keydown (discrete events) are now flushed synchronously before the browser paints, consistent with `useLayoutEffect` for those cases.
|
||||
|
||||
- **Hydration mismatches treated as errors** (18 / improved in 19): Text content mismatches between server HTML and client render revert to client rendering up to the nearest `<Suspense>` boundary. React 19 logs a single diff instead of multiple warnings, making mismatches much easier to diagnose.
|
||||
|
||||
- **New JSX transform required** (19): The automatic JSX runtime introduced in 2020 (`react/jsx-runtime`) is now mandatory. The classic transform (which required `import React from 'react'` in every file) is no longer supported. Most toolchains have already shipped the new transform; check your Babel or TypeScript config if you see warnings.
|
||||
|
||||
- **UMD builds removed** (19): React no longer ships UMD bundles. Load via npm and a bundler, or use an ESM CDN (`import React from "https://esm.sh/react@19"`).
|
||||
|
||||
- **React Compiler automatic memoization** (Compiler 1.0): Build-time Babel plugin that inserts memoization into components and hooks. Components that follow the Rules of React are automatically memoized; components that violate them are silently skipped (no build error, no runtime change). The compiler can memoize conditionally and after early returns — things impossible with manual `useMemo`/`useCallback`. Works with React 17+ via `react-compiler-runtime`; best with React 19+. Projects adopt incrementally via path-based Babel overrides, `compilationMode: 'annotation'`, or the `"use memo"` / `"use no memo"` directives. Check the project's Vite/Babel config to know which paths are compiled. Compiled components show a "Memo ✨" badge in React DevTools.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Modern TypeScript (5.0–6.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.
|
||||
@@ -82,9 +82,6 @@ updates:
|
||||
mui:
|
||||
patterns:
|
||||
- "@mui*"
|
||||
radix:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
|
||||
# Needed for helm chart linting
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: v3.9.2
|
||||
continue-on-error: true
|
||||
@@ -870,7 +870,7 @@ jobs:
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
|
||||
uses: chromaui/action@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -902,7 +902,7 @@ jobs:
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
|
||||
uses: chromaui/action@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
options:
|
||||
- mainline
|
||||
- stable
|
||||
- rc
|
||||
release_notes:
|
||||
description: Release notes for the publishing the release. This is required to create a release.
|
||||
dry_run:
|
||||
@@ -119,9 +120,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2.10.2 -> release/2.10
|
||||
# Derive the release branch from the version tag.
|
||||
# Standard: 2.10.2 -> release/2.10
|
||||
# RC: 2.32.0-rc.0 -> release/2.32-rc.0
|
||||
version="$(./scripts/version.sh)"
|
||||
release_branch=release/${version%.*}
|
||||
if [[ "$version" == *-rc.* ]]; then
|
||||
# Extract major.minor and rc suffix from e.g. 2.32.0-rc.0
|
||||
base_version="${version%%-rc.*}" # 2.32.0
|
||||
major_minor="${base_version%.*}" # 2.32
|
||||
rc_suffix="${version##*-rc.}" # 0
|
||||
release_branch="release/${major_minor}-rc.${rc_suffix}"
|
||||
else
|
||||
release_branch=release/${version%.*}
|
||||
fi
|
||||
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
|
||||
if [[ -z "${branch_contains_tag}" ]]; then
|
||||
echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?"
|
||||
@@ -531,6 +542,9 @@ jobs:
|
||||
if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then
|
||||
publish_args+=(--stable)
|
||||
fi
|
||||
if [[ $CODER_RELEASE_CHANNEL == "rc" ]]; then
|
||||
publish_args+=(--rc)
|
||||
fi
|
||||
if [[ $CODER_DRY_RUN == *t* ]]; then
|
||||
publish_args+=(--dry-run)
|
||||
fi
|
||||
@@ -643,7 +657,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }}
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
@@ -731,7 +745,7 @@ jobs:
|
||||
name: Publish to winget-pkgs
|
||||
runs-on: windows-latest
|
||||
needs: release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -49,8 +49,10 @@ 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
|
||||
run: corepack enable pnpm
|
||||
- name: Enable corepack and create pnpm store
|
||||
run: |
|
||||
corepack enable pnpm
|
||||
mkdir -p "$(pnpm store path --silent)"
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: umbrelladocs/action-linkspector@37c85bcde51b30bf929936502bac6bfb7e8f0a4d # v1.4.1
|
||||
|
||||
+6
-6
@@ -403,6 +403,12 @@ func (a *agent) init() {
|
||||
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,
|
||||
@@ -1265,12 +1271,6 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
return xerrors.Errorf("update workspace agent startup: %w", err)
|
||||
}
|
||||
|
||||
// Initialize the context config API with the expanded
|
||||
// working directory so that it is ready before the HTTP
|
||||
// handler is created (which happens after manifestOK).
|
||||
a.contextConfigAPI = agentcontextconfig.NewAPI(
|
||||
manifest.Directory,
|
||||
)
|
||||
oldManifest := a.manifest.Swap(&manifest)
|
||||
manifestOK.complete(nil)
|
||||
sentResult = true
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -8,10 +10,22 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontextconfig"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
agentsdk "github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// platformAbsPath constructs an absolute path that is valid
|
||||
// on the current platform. On Windows, paths must include a
|
||||
// drive letter to be considered absolute.
|
||||
func platformAbsPath(parts ...string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `C:\` + filepath.Join(parts...)
|
||||
}
|
||||
return "/" + filepath.Join(parts...)
|
||||
}
|
||||
|
||||
// TestReportConnectionEmpty tests that reportConnection() doesn't choke if given an empty IP string, which is what we
|
||||
// send if we cannot get the remote address.
|
||||
func TestReportConnectionEmpty(t *testing.T) {
|
||||
@@ -42,3 +56,41 @@ func TestReportConnectionEmpty(t *testing.T) {
|
||||
require.Equal(t, proto.Connection_DISCONNECT, req1.GetConnection().GetAction())
|
||||
require.Equal(t, "because", req1.GetConnection().GetReason())
|
||||
}
|
||||
|
||||
func TestContextConfigAPI_InitOnce(t *testing.T) {
|
||||
// Not parallel: uses t.Setenv to clear env vars.
|
||||
|
||||
// Clear env vars so defaults are used and the test is
|
||||
// hermetic regardless of the surrounding environment.
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
// After the fix, contextConfigAPI is set once in init() and
|
||||
// never reassigned. Config() evaluates lazily via the
|
||||
// manifest, so there is no concurrent write to race with.
|
||||
dir1 := platformAbsPath("dir1")
|
||||
dir2 := platformAbsPath("dir2")
|
||||
|
||||
a := &agent{}
|
||||
a.manifest.Store(&agentsdk.Manifest{Directory: dir1})
|
||||
a.contextConfigAPI = agentcontextconfig.NewAPI(func() string {
|
||||
if m := a.manifest.Load(); m != nil {
|
||||
return m.Directory
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
cfg1 := a.contextConfigAPI.Config()
|
||||
require.NotEmpty(t, cfg1.MCPConfigFiles)
|
||||
require.Contains(t, cfg1.MCPConfigFiles[0], dir1)
|
||||
|
||||
// Simulate manifest update on reconnection — no field
|
||||
// reassignment needed, the lazy closure picks it up.
|
||||
a.manifest.Store(&agentsdk.Manifest{Directory: dir2})
|
||||
cfg2 := a.contextConfigAPI.Config()
|
||||
require.NotEmpty(t, cfg2.MCPConfigFiles)
|
||||
require.Contains(t, cfg2.MCPConfigFiles[0], dir2)
|
||||
}
|
||||
|
||||
+15
-8
@@ -3007,7 +3007,7 @@ func TestAgent_Speedtest(t *testing.T) {
|
||||
|
||||
func TestAgent_Reconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := testutil.Logger(t)
|
||||
// After the agent is disconnected from a coordinator, it's supposed
|
||||
// to reconnect!
|
||||
@@ -3020,7 +3020,8 @@ func TestAgent_Reconnect(t *testing.T) {
|
||||
logger,
|
||||
agentID,
|
||||
agentsdk.Manifest{
|
||||
DERPMap: derpMap,
|
||||
DERPMap: derpMap,
|
||||
Directory: "/test/workspace",
|
||||
},
|
||||
statsCh,
|
||||
fCoordinator,
|
||||
@@ -3033,13 +3034,19 @@ func TestAgent_Reconnect(t *testing.T) {
|
||||
})
|
||||
defer closer.Close()
|
||||
|
||||
call1 := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
|
||||
require.Equal(t, client.GetNumRefreshTokenCalls(), 1)
|
||||
close(call1.Resps) // hang up
|
||||
// expect reconnect
|
||||
// Each iteration forces the agent to reconnect by closing
|
||||
// the current coordinate call while the tracked HTTP server
|
||||
// goroutine (from connection 1's createTailnet) is still
|
||||
// alive, widening the race window.
|
||||
const reconnections = 5
|
||||
for i := range reconnections {
|
||||
call := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
|
||||
require.Equal(t, i+1, client.GetNumRefreshTokenCalls())
|
||||
close(call.Resps) // hang up — triggers reconnect
|
||||
}
|
||||
// Verify final reconnect succeeds.
|
||||
testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
|
||||
// Check that the agent refreshes the token when it reconnects.
|
||||
require.Equal(t, client.GetNumRefreshTokenCalls(), 2)
|
||||
require.Equal(t, reconnections+1, client.GetNumRefreshTokenCalls())
|
||||
closer.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,16 +29,17 @@ const (
|
||||
// API exposes the resolved context configuration through the
|
||||
// agent's HTTP API.
|
||||
type API struct {
|
||||
config workspacesdk.ContextConfigResponse
|
||||
workingDir func() string
|
||||
}
|
||||
|
||||
// NewAPI reads context configuration from environment variables,
|
||||
// resolves all paths relative to workingDir, and returns an API
|
||||
// handler that serves the result.
|
||||
func NewAPI(workingDir string) *API {
|
||||
return &API{
|
||||
config: Config(workingDir),
|
||||
// NewAPI accepts a closure that returns the working directory.
|
||||
// The directory is evaluated lazily on each call to Config(),
|
||||
// so the caller can update it after construction.
|
||||
func NewAPI(workingDir func() string) *API {
|
||||
if workingDir == nil {
|
||||
workingDir = func() string { return "" }
|
||||
}
|
||||
return &API{workingDir: workingDir}
|
||||
}
|
||||
|
||||
// Config reads env vars and resolves paths. Exported for use
|
||||
@@ -67,7 +68,7 @@ func Config(workingDir string) workspacesdk.ContextConfigResponse {
|
||||
// Config returns the resolved config for use by other agent
|
||||
// components (e.g. MCP manager).
|
||||
func (api *API) Config() workspacesdk.ContextConfigResponse {
|
||||
return api.config
|
||||
return Config(api.workingDir())
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for the context config
|
||||
@@ -79,5 +80,5 @@ func (api *API) Routes() http.Handler {
|
||||
}
|
||||
|
||||
func (api *API) handleGet(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, api.config)
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, api.Config())
|
||||
}
|
||||
|
||||
@@ -93,3 +93,25 @@ func TestConfig(t *testing.T) {
|
||||
require.Equal(t, []string{a, b}, cfg.InstructionsDirs)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewAPI_LazyDirectory(t *testing.T) {
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
dir := ""
|
||||
api := agentcontextconfig.NewAPI(func() string { return dir })
|
||||
|
||||
// Before directory is set, relative paths resolve to nothing.
|
||||
cfg := api.Config()
|
||||
require.Empty(t, cfg.SkillsDirs)
|
||||
require.Empty(t, cfg.MCPConfigFiles)
|
||||
|
||||
// After setting the directory, Config() picks it up lazily.
|
||||
dir = platformAbsPath("work")
|
||||
cfg = api.Config()
|
||||
require.NotEmpty(t, cfg.SkillsDirs)
|
||||
require.Equal(t, []string{filepath.Join(dir, ".agents", "skills")}, cfg.SkillsDirs)
|
||||
}
|
||||
|
||||
Generated
+3
@@ -12894,6 +12894,9 @@ const docTemplate = `{
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
|
||||
Generated
+3
@@ -11472,6 +11472,9 @@
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
|
||||
@@ -999,15 +999,16 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
|
||||
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
|
||||
})
|
||||
intc := codersdk.AIBridgeInterception{
|
||||
ID: interception.ID,
|
||||
Initiator: MinimalUserFromVisibleUser(initiator),
|
||||
Provider: interception.Provider,
|
||||
Model: interception.Model,
|
||||
Metadata: jsonOrEmptyMap(interception.Metadata),
|
||||
StartedAt: interception.StartedAt,
|
||||
TokenUsages: sdkTokenUsages,
|
||||
UserPrompts: sdkUserPrompts,
|
||||
ToolUsages: sdkToolUsages,
|
||||
ID: interception.ID,
|
||||
Initiator: MinimalUserFromVisibleUser(initiator),
|
||||
Provider: interception.Provider,
|
||||
ProviderName: interception.ProviderName,
|
||||
Model: interception.Model,
|
||||
Metadata: jsonOrEmptyMap(interception.Metadata),
|
||||
StartedAt: interception.StartedAt,
|
||||
TokenUsages: sdkTokenUsages,
|
||||
UserPrompts: sdkUserPrompts,
|
||||
ToolUsages: sdkToolUsages,
|
||||
}
|
||||
if interception.APIKeyID.Valid {
|
||||
intc.APIKeyID = &interception.APIKeyID.String
|
||||
|
||||
@@ -721,7 +721,9 @@ func (s *MethodTestSuite) TestChats() {
|
||||
check.Args(threshold).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(chats)
|
||||
}))
|
||||
s.Run("InsertChat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
arg := testutil.Fake(s.T(), faker, database.InsertChatParams{})
|
||||
arg := testutil.Fake(s.T(), faker, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
})
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{OwnerID: arg.OwnerID})
|
||||
dbm.EXPECT().InsertChat(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()), policy.ActionCreate).Returns(chat)
|
||||
|
||||
@@ -1591,6 +1591,7 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
|
||||
APIKeyID: seed.APIKeyID,
|
||||
InitiatorID: takeFirst(seed.InitiatorID, uuid.New()),
|
||||
Provider: takeFirst(seed.Provider, "provider"),
|
||||
ProviderName: takeFirst(seed.ProviderName, "provider-name"),
|
||||
Model: takeFirst(seed.Model, "model"),
|
||||
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
|
||||
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
|
||||
|
||||
Generated
+4
-1
@@ -1100,7 +1100,8 @@ CREATE TABLE aibridge_interceptions (
|
||||
thread_parent_id uuid,
|
||||
thread_root_id uuid,
|
||||
client_session_id character varying(256),
|
||||
session_id text GENERATED ALWAYS AS (COALESCE(client_session_id, ((thread_root_id)::text)::character varying, ((id)::text)::character varying)) STORED NOT NULL
|
||||
session_id text GENERATED ALWAYS AS (COALESCE(client_session_id, ((thread_root_id)::text)::character varying, ((id)::text)::character varying)) STORED NOT NULL,
|
||||
provider_name text DEFAULT ''::text NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge';
|
||||
@@ -1115,6 +1116,8 @@ COMMENT ON COLUMN aibridge_interceptions.client_session_id IS 'The session ID su
|
||||
|
||||
COMMENT ON COLUMN aibridge_interceptions.session_id IS 'Groups related interceptions into a logical session. Determined by a priority chain: (1) client_session_id — an explicit session identifier supplied by the calling client (e.g. Claude Code); (2) thread_root_id — the root of an agentic thread detected by Bridge through tool-call correlation, used when the client does not supply its own session ID; (3) id — the interception''s own ID, used as a last resort so every interception belongs to exactly one session even if it is standalone. This is a generated column stored on disk so it can be indexed and joined without recomputing the COALESCE on every query.';
|
||||
|
||||
COMMENT ON COLUMN aibridge_interceptions.provider_name IS 'The provider instance name which may differ from provider when multiple instances of the same provider type exist.';
|
||||
|
||||
CREATE TABLE aibridge_model_thoughts (
|
||||
interception_id uuid NOT NULL,
|
||||
content text NOT NULL,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE aibridge_interceptions DROP COLUMN provider_name;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE aibridge_interceptions ADD COLUMN provider_name TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN aibridge_interceptions.provider_name IS 'The provider instance name which may differ from provider when multiple instances of the same provider type exist.';
|
||||
|
||||
-- Backfill existing records with the provider type as the provider name.
|
||||
UPDATE aibridge_interceptions SET provider_name = provider WHERE provider_name = '';
|
||||
@@ -865,6 +865,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
|
||||
&i.AIBridgeInterception.ThreadRootID,
|
||||
&i.AIBridgeInterception.ClientSessionID,
|
||||
&i.AIBridgeInterception.SessionID,
|
||||
&i.AIBridgeInterception.ProviderName,
|
||||
&i.VisibleUser.ID,
|
||||
&i.VisibleUser.Username,
|
||||
&i.VisibleUser.Name,
|
||||
@@ -1125,6 +1126,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -4038,6 +4038,8 @@ type AIBridgeInterception struct {
|
||||
ClientSessionID sql.NullString `db:"client_session_id" json:"client_session_id"`
|
||||
// Groups related interceptions into a logical session. Determined by a priority chain: (1) client_session_id — an explicit session identifier supplied by the calling client (e.g. Claude Code); (2) thread_root_id — the root of an agentic thread detected by Bridge through tool-call correlation, used when the client does not supply its own session ID; (3) id — the interception's own ID, used as a last resort so every interception belongs to exactly one session even if it is standalone. This is a generated column stored on disk so it can be indexed and joined without recomputing the COALESCE on every query.
|
||||
SessionID string `db:"session_id" json:"session_id"`
|
||||
// The provider instance name which may differ from provider when multiple instances of the same provider type exist.
|
||||
ProviderName string `db:"provider_name" json:"provider_name"`
|
||||
}
|
||||
|
||||
// Audit log of model thinking in intercepted requests in AI Bridge
|
||||
|
||||
@@ -1285,6 +1285,7 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
// Create 3 chats owned by owner.
|
||||
for i := range 3 {
|
||||
_, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: fmt.Sprintf("owner chat %d", i+1),
|
||||
@@ -1295,6 +1296,7 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
// Create 2 chats owned by member.
|
||||
for i := range 2 {
|
||||
_, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: member.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: fmt.Sprintf("member chat %d", i+1),
|
||||
@@ -1416,6 +1418,7 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
})
|
||||
for i := range 7 {
|
||||
_, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: paginationUser.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: fmt.Sprintf("pagination chat %d", i+1),
|
||||
@@ -9472,6 +9475,7 @@ func TestInsertChatMessages(t *testing.T) {
|
||||
)
|
||||
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelConfigA.ID,
|
||||
Title: "test-chat-" + uuid.NewString(),
|
||||
@@ -9641,6 +9645,7 @@ func TestGetChatMessagesForPromptByChatID(t *testing.T) {
|
||||
newChat := func(t *testing.T) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "test-chat-" + uuid.NewString(),
|
||||
@@ -10014,6 +10019,7 @@ func TestGetPRInsights(t *testing.T) {
|
||||
createChat := func(t *testing.T, store database.Store, userID, mcID uuid.UUID, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(context.Background(), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: userID,
|
||||
LastModelConfigID: mcID,
|
||||
Title: title,
|
||||
@@ -10149,6 +10155,7 @@ func TestGetPRInsights(t *testing.T) {
|
||||
createChildChat := func(t *testing.T, store database.Store, userID, mcID, parentID, rootID uuid.UUID, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(context.Background(), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: userID,
|
||||
LastModelConfigID: mcID,
|
||||
Title: title,
|
||||
@@ -10538,6 +10545,7 @@ func TestChatPinOrderQueries(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: ownerID,
|
||||
LastModelConfigID: modelCfgID,
|
||||
Title: title,
|
||||
@@ -10718,6 +10726,7 @@ func TestChatLabels(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "labeled-chat",
|
||||
@@ -10740,6 +10749,7 @@ func TestChatLabels(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "no-labels-chat",
|
||||
@@ -10755,6 +10765,7 @@ func TestChatLabels(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "update-labels-chat",
|
||||
@@ -10795,6 +10806,7 @@ func TestChatLabels(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "original-title",
|
||||
@@ -10831,6 +10843,7 @@ func TestChatLabels(t *testing.T) {
|
||||
labelsJSON, err := json.Marshal(tc.labels)
|
||||
require.NoError(t, err)
|
||||
_, err = db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: tc.title,
|
||||
@@ -10916,6 +10929,7 @@ func TestChatHasUnread(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "test-chat-" + uuid.NewString(),
|
||||
|
||||
@@ -455,7 +455,7 @@ func (q *sqlQuerier) DeleteOldAIBridgeRecords(ctx context.Context, beforeTime ti
|
||||
|
||||
const getAIBridgeInterceptionByID = `-- name: GetAIBridgeInterceptionByID :one
|
||||
SELECT
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
WHERE
|
||||
@@ -479,6 +479,7 @@ func (q *sqlQuerier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UU
|
||||
&i.ThreadRootID,
|
||||
&i.ClientSessionID,
|
||||
&i.SessionID,
|
||||
&i.ProviderName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -513,7 +514,7 @@ func (q *sqlQuerier) GetAIBridgeInterceptionLineageByToolCallID(ctx context.Cont
|
||||
|
||||
const getAIBridgeInterceptions = `-- name: GetAIBridgeInterceptions :many
|
||||
SELECT
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
`
|
||||
@@ -541,6 +542,7 @@ func (q *sqlQuerier) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeIn
|
||||
&i.ThreadRootID,
|
||||
&i.ClientSessionID,
|
||||
&i.SessionID,
|
||||
&i.ProviderName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -687,11 +689,11 @@ func (q *sqlQuerier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context,
|
||||
|
||||
const insertAIBridgeInterception = `-- name: InsertAIBridgeInterception :one
|
||||
INSERT INTO aibridge_interceptions (
|
||||
id, api_key_id, initiator_id, provider, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
|
||||
id, api_key_id, initiator_id, provider, provider_name, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, COALESCE($6::jsonb, '{}'::jsonb), $7, $8, $9, $10::uuid, $11::uuid
|
||||
$1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb), $8, $9, $10, $11::uuid, $12::uuid
|
||||
)
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name
|
||||
`
|
||||
|
||||
type InsertAIBridgeInterceptionParams struct {
|
||||
@@ -699,6 +701,7 @@ type InsertAIBridgeInterceptionParams struct {
|
||||
APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"`
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
Provider string `db:"provider" json:"provider"`
|
||||
ProviderName string `db:"provider_name" json:"provider_name"`
|
||||
Model string `db:"model" json:"model"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
@@ -714,6 +717,7 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA
|
||||
arg.APIKeyID,
|
||||
arg.InitiatorID,
|
||||
arg.Provider,
|
||||
arg.ProviderName,
|
||||
arg.Model,
|
||||
arg.Metadata,
|
||||
arg.StartedAt,
|
||||
@@ -737,6 +741,7 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA
|
||||
&i.ThreadRootID,
|
||||
&i.ClientSessionID,
|
||||
&i.SessionID,
|
||||
&i.ProviderName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -963,7 +968,7 @@ func (q *sqlQuerier) ListAIBridgeClients(ctx context.Context, arg ListAIBridgeCl
|
||||
|
||||
const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many
|
||||
SELECT
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id,
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id, aibridge_interceptions.provider_name,
|
||||
visible_users.id, visible_users.username, visible_users.name, visible_users.avatar_url
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
@@ -1076,6 +1081,7 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
|
||||
&i.AIBridgeInterception.ThreadRootID,
|
||||
&i.AIBridgeInterception.ClientSessionID,
|
||||
&i.AIBridgeInterception.SessionID,
|
||||
&i.AIBridgeInterception.ProviderName,
|
||||
&i.VisibleUser.ID,
|
||||
&i.VisibleUser.Username,
|
||||
&i.VisibleUser.Name,
|
||||
@@ -1271,7 +1277,7 @@ WITH paginated_threads AS (
|
||||
)
|
||||
SELECT
|
||||
COALESCE(aibridge_interceptions.thread_root_id, aibridge_interceptions.id) AS thread_id,
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id, aibridge_interceptions.provider_name
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
JOIN
|
||||
@@ -1332,6 +1338,7 @@ func (q *sqlQuerier) ListAIBridgeSessionThreads(ctx context.Context, arg ListAIB
|
||||
&i.AIBridgeInterception.ThreadRootID,
|
||||
&i.AIBridgeInterception.ClientSessionID,
|
||||
&i.AIBridgeInterception.SessionID,
|
||||
&i.AIBridgeInterception.ProviderName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1706,7 +1713,7 @@ UPDATE aibridge_interceptions
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
AND ended_at IS NULL
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name
|
||||
`
|
||||
|
||||
type UpdateAIBridgeInterceptionEndedParams struct {
|
||||
@@ -1731,6 +1738,7 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
|
||||
&i.ThreadRootID,
|
||||
&i.ClientSessionID,
|
||||
&i.SessionID,
|
||||
&i.ProviderName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -5707,6 +5715,7 @@ INSERT INTO chats (
|
||||
last_model_config_id,
|
||||
title,
|
||||
mode,
|
||||
status,
|
||||
mcp_server_ids,
|
||||
labels
|
||||
) VALUES (
|
||||
@@ -5719,8 +5728,9 @@ INSERT INTO chats (
|
||||
$7::uuid,
|
||||
$8::text,
|
||||
$9::chat_mode,
|
||||
COALESCE($10::uuid[], '{}'::uuid[]),
|
||||
COALESCE($11::jsonb, '{}'::jsonb)
|
||||
$10::chat_status,
|
||||
COALESCE($11::uuid[], '{}'::uuid[]),
|
||||
COALESCE($12::jsonb, '{}'::jsonb)
|
||||
)
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context
|
||||
@@ -5736,6 +5746,7 @@ type InsertChatParams struct {
|
||||
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Mode NullChatMode `db:"mode" json:"mode"`
|
||||
Status ChatStatus `db:"status" json:"status"`
|
||||
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
|
||||
Labels pqtype.NullRawMessage `db:"labels" json:"labels"`
|
||||
}
|
||||
@@ -5751,6 +5762,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
|
||||
arg.LastModelConfigID,
|
||||
arg.Title,
|
||||
arg.Mode,
|
||||
arg.Status,
|
||||
pq.Array(arg.MCPServerIDs),
|
||||
arg.Labels,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
-- name: InsertAIBridgeInterception :one
|
||||
INSERT INTO aibridge_interceptions (
|
||||
id, api_key_id, initiator_id, provider, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
|
||||
id, api_key_id, initiator_id, provider, provider_name, model, metadata, started_at, client, client_session_id, thread_parent_id, thread_root_id
|
||||
) VALUES (
|
||||
@id, @api_key_id, @initiator_id, @provider, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at, @client, sqlc.narg('client_session_id'), sqlc.narg('thread_parent_interception_id')::uuid, sqlc.narg('thread_root_interception_id')::uuid
|
||||
@id, @api_key_id, @initiator_id, @provider, @provider_name, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at, @client, sqlc.narg('client_session_id'), sqlc.narg('thread_parent_interception_id')::uuid, sqlc.narg('thread_root_interception_id')::uuid
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@ INSERT INTO chats (
|
||||
last_model_config_id,
|
||||
title,
|
||||
mode,
|
||||
status,
|
||||
mcp_server_ids,
|
||||
labels
|
||||
) VALUES (
|
||||
@@ -404,6 +405,7 @@ INSERT INTO chats (
|
||||
@last_model_config_id::uuid,
|
||||
@title::text,
|
||||
sqlc.narg('mode')::chat_mode,
|
||||
@status::chat_status,
|
||||
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
|
||||
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb)
|
||||
)
|
||||
|
||||
@@ -251,10 +251,17 @@ func TestPostChats(t *testing.T) {
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
// Member without agents-access should be denied.
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
// Strip the auto-assigned agents-access role to test
|
||||
// the denied case.
|
||||
_, err := client.Client.UpdateUserRoles(ctx, member.Username, codersdk.UpdateRoles{
|
||||
Roles: []string{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
@@ -264,7 +271,6 @@ func TestPostChats(t *testing.T) {
|
||||
})
|
||||
requireSDKError(t, err, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("HidesSystemPromptMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -495,6 +501,7 @@ func TestPostChats(t *testing.T) {
|
||||
wantResetsAt := enableDailyChatUsageLimit(ctx, t, db, 100)
|
||||
|
||||
existingChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "existing-limit-chat",
|
||||
@@ -547,6 +554,7 @@ func TestListChats(t *testing.T) {
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
memberDBChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: member.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "member chat only",
|
||||
@@ -626,7 +634,16 @@ func TestListChats(t *testing.T) {
|
||||
// returning empty because no chats exist.
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
_, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
|
||||
// Strip the auto-assigned agents-access role to test
|
||||
// the denied case.
|
||||
_, err := client.Client.UpdateUserRoles(ctx, member.Username, codersdk.UpdateRoles{
|
||||
Roles: []string{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: member.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "member chat",
|
||||
@@ -941,6 +958,7 @@ func TestWatchChats(t *testing.T) {
|
||||
|
||||
// Insert a chat and a diff status row.
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "diff status watch test",
|
||||
@@ -1068,6 +1086,7 @@ func TestWatchChats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
childOne, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "watch child 1",
|
||||
@@ -1077,6 +1096,7 @@ func TestWatchChats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
childTwo, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "watch child 2",
|
||||
@@ -2207,6 +2227,7 @@ func TestArchiveChat(t *testing.T) {
|
||||
|
||||
// Insert child chats directly via the database.
|
||||
child1, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "child 1",
|
||||
@@ -2216,6 +2237,7 @@ func TestArchiveChat(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
child2, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "child 2",
|
||||
@@ -2322,6 +2344,7 @@ func TestUnarchiveChat(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
child1, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "child 1",
|
||||
@@ -2331,6 +2354,7 @@ func TestUnarchiveChat(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
child2, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "child 2",
|
||||
@@ -3661,6 +3685,7 @@ func TestInterruptChat(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "interrupt route test",
|
||||
@@ -3740,6 +3765,7 @@ func TestRegenerateChatTitle(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "chat with update denied",
|
||||
@@ -3848,6 +3874,7 @@ func TestRegenerateChatTitle(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "chat with lock held",
|
||||
@@ -3888,6 +3915,7 @@ func TestRegenerateChatTitle(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "pending chat without worker",
|
||||
@@ -4004,6 +4032,7 @@ func TestGetChatDiffStatus(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
noCachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "get diff status route no cache",
|
||||
@@ -4016,6 +4045,7 @@ func TestGetChatDiffStatus(t *testing.T) {
|
||||
require.Nil(t, noCachedChat.DiffStatus)
|
||||
|
||||
cachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "get diff status route cached",
|
||||
@@ -4122,6 +4152,7 @@ func TestGetChatDiffContents(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "diff contents with cached repository reference",
|
||||
@@ -4218,6 +4249,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "delete queued message route test",
|
||||
@@ -4269,6 +4301,7 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "delete queued invalid id",
|
||||
@@ -4303,6 +4336,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "promote queued message route test",
|
||||
@@ -4373,6 +4407,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
|
||||
enableDailyChatUsageLimit(ctx, t, db, 100)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "promote queued usage limit",
|
||||
@@ -4447,6 +4482,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "promote queued invalid id",
|
||||
@@ -5029,6 +5065,7 @@ func seedChatCostFixture(t *testing.T) chatCostTestFixture {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "test chat",
|
||||
@@ -5146,6 +5183,7 @@ func TestChatCostSummary_AdminDrilldown(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(seedCtx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: member.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "member chat",
|
||||
@@ -5214,6 +5252,7 @@ func TestChatCostUsers(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
adminChat, err := db.InsertChat(dbauthz.AsSystemRestricted(seedCtx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "admin chat",
|
||||
@@ -5241,6 +5280,7 @@ func TestChatCostUsers(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
memberChat, err := db.InsertChat(dbauthz.AsSystemRestricted(seedCtx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: member.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "member chat",
|
||||
@@ -5324,6 +5364,7 @@ func TestChatCostSummary_DateRange(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(seedCtx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "date range test",
|
||||
@@ -5389,6 +5430,7 @@ func TestChatCostSummary_UnpricedMessages(t *testing.T) {
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "unpriced test",
|
||||
@@ -6455,6 +6497,7 @@ func TestGetChatsByWorkspace(t *testing.T) {
|
||||
// Helper to insert a chat linked to a workspace.
|
||||
insertChat := func(ctx context.Context, title string, workspaceID uuid.UUID) database.Chat {
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: title,
|
||||
|
||||
@@ -62,6 +62,7 @@ func TestChatParam(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(context.Background(), database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: ownerID,
|
||||
WorkspaceID: uuid.NullUUID{},
|
||||
ParentChatID: uuid.NullUUID{},
|
||||
|
||||
@@ -1619,6 +1619,18 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
|
||||
rbacRoles = req.RBACRoles
|
||||
}
|
||||
|
||||
// When the agents experiment is enabled, auto-assign the
|
||||
// agents-access role so new users can use Coder Agents
|
||||
// without manual admin intervention. Skip this for OIDC
|
||||
// users when site role sync is enabled, because the sync
|
||||
// will overwrite roles on every login anyway — those
|
||||
// admins should use --oidc-user-role-default instead.
|
||||
if api.Experiments.Enabled(codersdk.ExperimentAgents) &&
|
||||
!(req.LoginType == database.LoginTypeOIDC && api.IDPSync.SiteRoleSyncEnabled()) &&
|
||||
!slices.Contains(rbacRoles, codersdk.RoleAgentsAccess) {
|
||||
rbacRoles = append(rbacRoles, codersdk.RoleAgentsAccess)
|
||||
}
|
||||
|
||||
var user database.User
|
||||
err := store.InTx(func(tx database.Store) error {
|
||||
orgRoles := make([]string, 0)
|
||||
|
||||
@@ -758,6 +758,35 @@ func TestPostUsers(t *testing.T) {
|
||||
assert.Equal(t, firstUser.OrganizationID, user.OrganizationIDs[0])
|
||||
})
|
||||
|
||||
// CreateWithAgentsExperiment verifies that new users
|
||||
// are auto-assigned the agents-access role when the
|
||||
// experiment is enabled. The experiment-disabled case
|
||||
// is implicitly covered by TestInitialRoles, which
|
||||
// asserts exactly [owner] with no experiment — it
|
||||
// would fail if agents-access leaked through.
|
||||
t.Run("CreateWithAgentsExperiment", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
client := coderdtest.New(t, &coderdtest.Options{DeploymentValues: dv})
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||
OrganizationIDs: []uuid.UUID{firstUser.OrganizationID},
|
||||
Email: "another@user.org",
|
||||
Username: "someone-else",
|
||||
Password: "SomeSecurePassword!",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
roles, err := client.UserRoles(ctx, user.Username)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, roles.Roles, codersdk.RoleAgentsAccess,
|
||||
"new user should have agents-access role when agents experiment is enabled")
|
||||
})
|
||||
|
||||
t.Run("CreateWithStatus", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
|
||||
@@ -515,7 +515,11 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
primaryAppHost, err := client.AppHost(appHostCtx)
|
||||
require.NoError(t, err)
|
||||
if primaryAppHost.Host != "" {
|
||||
rpcConn, err := agentClient.ConnectRPC(appHostCtx)
|
||||
// Fetch the manifest without marking this short-lived helper
|
||||
// connection as the workspace agent. Closing a monitored RPC
|
||||
// connection races with the real agent startup and can
|
||||
// transiently mark the agent disconnected.
|
||||
rpcConn, err := agentClient.ConnectRPCWithRole(appHostCtx, "apptest-manifest")
|
||||
require.NoError(t, err)
|
||||
aAPI := agentproto.NewDRPCAgentClient(rpcConn)
|
||||
manifest, err := aAPI.GetManifest(appHostCtx, &agentproto.GetManifestRequest{})
|
||||
|
||||
+8
-35
@@ -93,7 +93,7 @@ const (
|
||||
defaultSubagentInstruction = "You are running as a delegated sub-agent chat. Complete the delegated task and provide clear, concise assistant responses for the parent agent."
|
||||
)
|
||||
|
||||
var errChatHasNoWorkspaceAgent = xerrors.New("chat has no workspace agent")
|
||||
var errChatHasNoWorkspaceAgent = xerrors.New("workspace has no running agent: the workspace is likely stopped. Use the start_workspace tool to start it")
|
||||
|
||||
// Server handles background processing of pending chats.
|
||||
type Server struct {
|
||||
@@ -344,7 +344,7 @@ func (c *turnWorkspaceContext) loadWorkspaceAgentLocked(
|
||||
}
|
||||
|
||||
if !chatSnapshot.WorkspaceID.Valid {
|
||||
return chatSnapshot, database.WorkspaceAgent{}, xerrors.New("chat has no workspace")
|
||||
return chatSnapshot, database.WorkspaceAgent{}, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one")
|
||||
}
|
||||
|
||||
if chatSnapshot.AgentID.Valid {
|
||||
@@ -851,7 +851,10 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
||||
LastModelConfigID: opts.ModelConfigID,
|
||||
Title: opts.Title,
|
||||
Mode: opts.ChatMode,
|
||||
MCPServerIDs: opts.MCPServerIDs,
|
||||
// Chats created with an initial user message start pending.
|
||||
// Waiting is reserved for idle chats with no pending work.
|
||||
Status: database.ChatStatusPending,
|
||||
MCPServerIDs: opts.MCPServerIDs,
|
||||
Labels: pqtype.NullRawMessage{
|
||||
RawMessage: labelsJSON,
|
||||
Valid: true,
|
||||
@@ -920,10 +923,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
||||
return xerrors.Errorf("insert initial chat messages: %w", err)
|
||||
}
|
||||
|
||||
chat, err = setChatPendingWithStore(ctx, tx, insertedChat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set chat pending: %w", err)
|
||||
}
|
||||
chat = insertedChat
|
||||
|
||||
if !chat.RootChatID.Valid && !chat.ParentChatID.Valid {
|
||||
chat.RootChatID = uuid.NullUUID{UUID: chat.ID, Valid: true}
|
||||
@@ -1997,33 +1997,6 @@ func (p *Server) RefreshStatus(ctx context.Context, chatID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setChatPendingWithStore(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chatID uuid.UUID,
|
||||
) (database.Chat, error) {
|
||||
chat, err := store.GetChatByID(ctx, chatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("get chat: %w", err)
|
||||
}
|
||||
if chat.Status == database.ChatStatusPending {
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
updatedChat, err := store.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusPending,
|
||||
WorkerID: uuid.NullUUID{},
|
||||
StartedAt: sql.NullTime{},
|
||||
HeartbeatAt: sql.NullTime{},
|
||||
LastError: sql.NullString{},
|
||||
})
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("set chat pending: %w", err)
|
||||
}
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
func (p *Server) setChatWaiting(ctx context.Context, chatID uuid.UUID) (database.Chat, error) {
|
||||
var updatedChat database.Chat
|
||||
err := p.db.InTx(func(tx database.Store) error {
|
||||
@@ -4468,7 +4441,7 @@ func (p *Server) runChat(
|
||||
workspaceCtx.chatStateMu.Unlock()
|
||||
|
||||
if !chatSnapshot.WorkspaceID.Valid {
|
||||
return uuid.Nil, xerrors.New("chat has no workspace")
|
||||
return uuid.Nil, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one")
|
||||
}
|
||||
|
||||
ws, err := p.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID)
|
||||
|
||||
@@ -62,8 +62,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
}
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-haiku-4-5",
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
}
|
||||
updatedChat := chat
|
||||
@@ -85,9 +85,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer cancelSub()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
require.Equal(t, "claude-haiku-4-5", req.Model)
|
||||
return chattest.AnthropicNonStreamingResponse(wantTitle)
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
require.Equal(t, "gpt-4o-mini", req.Model)
|
||||
return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}")
|
||||
})
|
||||
|
||||
server := &Server{
|
||||
@@ -99,7 +99,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{
|
||||
Provider: "anthropic",
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
BaseUrl: serverURL,
|
||||
}}, nil)
|
||||
@@ -221,8 +221,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
lockedChat.StartedAt = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-haiku-4-5",
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
}
|
||||
updatedChat := lockedChat
|
||||
@@ -247,9 +247,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
require.NoError(t, err)
|
||||
defer cancelSub()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
require.Equal(t, "claude-haiku-4-5", req.Model)
|
||||
return chattest.AnthropicNonStreamingResponse(wantTitle)
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
require.Equal(t, "gpt-4o-mini", req.Model)
|
||||
return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}")
|
||||
})
|
||||
|
||||
server := &Server{
|
||||
@@ -261,7 +261,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{
|
||||
Provider: "anthropic",
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
BaseUrl: serverURL,
|
||||
}}, nil)
|
||||
|
||||
@@ -908,6 +908,7 @@ func TestCreateChatRejectsWhenUsageLimitReached(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
existingChat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
Title: "existing-limit-chat",
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -1198,6 +1199,7 @@ func TestInterruptAutoPromotionIgnoresLaterUsageLimitIncrease(t *testing.T) {
|
||||
require.NotNil(t, laterQueuedResult.QueuedMessage)
|
||||
|
||||
spendChat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{},
|
||||
ParentChatID: uuid.NullUUID{},
|
||||
@@ -1448,6 +1450,7 @@ func TestRecoverStaleChatsPeriodically(t *testing.T) {
|
||||
// to running with a heartbeat in the past.
|
||||
deadWorkerID := uuid.New()
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
Title: "stale-recovery-periodic",
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -1493,6 +1496,7 @@ func TestRecoverStaleChatsPeriodically(t *testing.T) {
|
||||
// This tests the periodic recovery, not just the startup one.
|
||||
deadWorkerID2 := uuid.New()
|
||||
chat2, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
Title: "stale-recovery-periodic-2",
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -1531,6 +1535,7 @@ func TestNewReplicaRecoversStaleChatFromDeadReplica(t *testing.T) {
|
||||
// heartbeat (well beyond the stale threshold).
|
||||
deadReplicaID := uuid.New()
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
Title: "orphaned-chat",
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -1573,6 +1578,7 @@ func TestWaitingChatsAreNotRecoveredAsStale(t *testing.T) {
|
||||
// Create a chat in waiting status — this should NOT be touched
|
||||
// by stale recovery.
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
Title: "waiting-chat",
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -1615,6 +1621,7 @@ func TestUpdateChatStatusPersistsLastError(t *testing.T) {
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
Title: "error-persisted",
|
||||
LastModelConfigID: model.ID,
|
||||
@@ -2479,7 +2486,7 @@ func TestStoppedWorkspaceWithPersistedAgentBindingDoesNotBlockChat(t *testing.T)
|
||||
if message.Role != "tool" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(message.Content, "chat has no workspace agent") {
|
||||
if strings.Contains(message.Content, "workspace has no running agent") {
|
||||
foundUnavailableToolResult = true
|
||||
break
|
||||
}
|
||||
@@ -2492,8 +2499,8 @@ func TestStoppedWorkspaceWithPersistedAgentBindingDoesNotBlockChat(t *testing.T)
|
||||
}
|
||||
errMsg, _ := toolResult["error"].(string)
|
||||
outputMsg, _ := toolResult["output"].(string)
|
||||
if strings.Contains(errMsg, "chat has no workspace agent") ||
|
||||
strings.Contains(outputMsg, "chat has no workspace agent") {
|
||||
if strings.Contains(errMsg, "workspace has no running agent") ||
|
||||
strings.Contains(outputMsg, "workspace has no running agent") {
|
||||
foundUnavailableToolResult = true
|
||||
break
|
||||
}
|
||||
@@ -2526,7 +2533,7 @@ func TestStoppedWorkspaceWithPersistedAgentBindingDoesNotBlockChat(t *testing.T)
|
||||
require.Equal(t, codersdk.ChatMessagePartTypeToolResult, parts[0].Type)
|
||||
require.Equal(t, "execute", parts[0].ToolName)
|
||||
require.True(t, parts[0].IsError)
|
||||
require.Contains(t, string(parts[0].Result), "chat has no workspace agent")
|
||||
require.Contains(t, string(parts[0].Result), "workspace has no running agent")
|
||||
}
|
||||
|
||||
func TestHeartbeatBumpsWorkspaceUsage(t *testing.T) {
|
||||
|
||||
@@ -1482,6 +1482,7 @@ func TestNulEscapeRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: model.ID,
|
||||
Title: "nul-roundtrip-test",
|
||||
@@ -1978,6 +1979,7 @@ func TestMediaToolResultRoundTrip(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
chat, chatErr := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: model.ID,
|
||||
Title: "media-roundtrip-" + callID,
|
||||
|
||||
@@ -670,7 +670,11 @@ func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp
|
||||
"created": resp.Created,
|
||||
"model": resp.Model,
|
||||
"output": outputs,
|
||||
"usage": resp.Usage,
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": resp.Usage.PromptTokens,
|
||||
"output_tokens": resp.Usage.CompletionTokens,
|
||||
"total_tokens": resp.Usage.TotalTokens,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
|
||||
@@ -35,6 +35,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
modelCfg := seedModelConfig(ctx, t, db, user.ID)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "test-no-workspace",
|
||||
@@ -77,6 +78,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
ws := wsResp.Workspace
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
@@ -155,6 +157,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
require.NotEqual(t, uuid.Nil, preferredAgentID)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
@@ -214,6 +217,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
ws := wsResp.Workspace
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
@@ -276,6 +280,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
ws := wsResp.Workspace
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
@@ -332,6 +337,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
ws := wsResp.Workspace
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
@@ -400,6 +406,7 @@ func TestStartWorkspace(t *testing.T) {
|
||||
ws := wsResp.Workspace
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
|
||||
+79
-13
@@ -2,12 +2,14 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/object"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
fantasyazure "charm.land/fantasy/providers/azure"
|
||||
fantasybedrock "charm.land/fantasy/providers/bedrock"
|
||||
@@ -27,6 +29,7 @@ import (
|
||||
)
|
||||
|
||||
const titleGenerationPrompt = "Write a short title for the user's message. " +
|
||||
"Populate the title field with the result. " +
|
||||
"Return only the title text in 2-8 words. " +
|
||||
"Do not answer the user or describe the title-writing task. " +
|
||||
"Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " +
|
||||
@@ -89,6 +92,10 @@ func normalizeShortTextOutput(text string) string {
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
type generatedTitle struct {
|
||||
Title string `json:"title" description:"Short descriptive chat title"`
|
||||
}
|
||||
|
||||
// maybeGenerateChatTitle generates an AI title for the chat when
|
||||
// appropriate (first user message, no assistant reply yet, and the
|
||||
// current title is either empty or still the fallback truncation).
|
||||
@@ -173,17 +180,79 @@ func generateTitle(
|
||||
model fantasy.LanguageModel,
|
||||
input string,
|
||||
) (string, error) {
|
||||
title, _, err := generateShortText(ctx, model, titleGenerationPrompt, input)
|
||||
title, _, err := generateStructuredTitle(ctx, model, titleGenerationPrompt, input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
title = normalizeTitleOutput(title)
|
||||
if title == "" {
|
||||
return "", xerrors.New("generated title was empty")
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func generateStructuredTitle(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
systemPrompt string,
|
||||
userInput string,
|
||||
) (string, fantasy.Usage, error) {
|
||||
userInput = strings.TrimSpace(userInput)
|
||||
if userInput == "" {
|
||||
return "", fantasy.Usage{}, xerrors.New("title input was empty")
|
||||
}
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: systemPrompt},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: userInput},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var maxOutputTokens int64 = 256
|
||||
var result *fantasy.ObjectResult[generatedTitle]
|
||||
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
|
||||
var genErr error
|
||||
result, genErr = object.Generate[generatedTitle](retryCtx, model, fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
SchemaName: "propose_title",
|
||||
SchemaDescription: "Propose a short chat title.",
|
||||
MaxOutputTokens: &maxOutputTokens,
|
||||
})
|
||||
return genErr
|
||||
}, nil)
|
||||
if err != nil {
|
||||
// Extract usage from the error when available so that
|
||||
// failed attempts are still accounted for in usage tracking.
|
||||
var usage fantasy.Usage
|
||||
var noObjErr *fantasy.NoObjectGeneratedError
|
||||
if errors.As(err, &noObjErr) {
|
||||
usage = noObjErr.Usage
|
||||
}
|
||||
return "", usage, xerrors.Errorf("generate structured title: %w", err)
|
||||
}
|
||||
|
||||
title := normalizeTitleOutput(result.Object.Title)
|
||||
if err := validateGeneratedTitle(title); err != nil {
|
||||
return "", result.Usage, err
|
||||
}
|
||||
return title, result.Usage, nil
|
||||
}
|
||||
|
||||
func validateGeneratedTitle(title string) error {
|
||||
if title == "" {
|
||||
return xerrors.New("generated title was empty")
|
||||
}
|
||||
if len(strings.Fields(title)) > 8 {
|
||||
return xerrors.New("generated title exceeded 8 words")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// titleInput returns the first user message text and whether title
|
||||
// generation should proceed. It returns false when the chat already
|
||||
// has assistant/tool replies, has more than one visible user message,
|
||||
@@ -400,7 +469,8 @@ func renderManualTitlePrompt(
|
||||
_, _ = prompt.WriteString(value)
|
||||
}
|
||||
|
||||
write("Write a short title for this AI coding conversation.\n\n")
|
||||
write("Write a short title for this AI coding conversation.\n")
|
||||
write("Populate the title field with the result.\n\n")
|
||||
write("Primary user objective:\n<primary_objective>\n")
|
||||
write(firstUserText)
|
||||
write("\n</primary_objective>")
|
||||
@@ -420,6 +490,7 @@ func renderManualTitlePrompt(
|
||||
|
||||
write("\n\nRequirements:\n")
|
||||
write("- Return only the title text in 2-8 words.\n")
|
||||
write("- Populate the title field only.\n")
|
||||
write("- Do not answer the user or describe the title-writing task.\n")
|
||||
write("- Preserve specific identifiers (PR numbers, repo names, file paths, function names, error messages).\n")
|
||||
write("- If the conversation is short or vague, stay close to the user's wording.\n")
|
||||
@@ -458,19 +529,14 @@ func generateManualTitle(
|
||||
userInput = strings.TrimSpace(firstUserText)
|
||||
}
|
||||
|
||||
title, usage, err := generateShortText(
|
||||
title, usage, err := generateStructuredTitle(
|
||||
titleCtx,
|
||||
fallbackModel,
|
||||
systemPrompt,
|
||||
userInput,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fantasy.Usage{}, err
|
||||
}
|
||||
|
||||
title = normalizeTitleOutput(title)
|
||||
if title == "" {
|
||||
return "", usage, xerrors.New("generated title was empty")
|
||||
return "", usage, err
|
||||
}
|
||||
|
||||
return title, usage, nil
|
||||
|
||||
@@ -376,7 +376,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
model := &stubModel{
|
||||
generateFn: func(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
generateObjectFn: func(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
require.True(t, ok, "manual title generation should set a deadline")
|
||||
require.WithinDuration(
|
||||
@@ -386,11 +386,8 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) {
|
||||
2*time.Second,
|
||||
)
|
||||
require.Len(t, call.Prompt, 2)
|
||||
return &fantasy.Response{
|
||||
Content: fantasy.ResponseContent{
|
||||
fantasy.TextContent{Text: "Refresh title"},
|
||||
},
|
||||
}, nil
|
||||
require.Equal(t, "propose_title", call.SchemaName)
|
||||
return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -417,7 +414,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
}
|
||||
|
||||
model := &stubModel{
|
||||
generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
generateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
require.Len(t, call.Prompt, 2)
|
||||
systemText, ok := call.Prompt[0].Content[0].(fantasy.TextPart)
|
||||
require.True(t, ok)
|
||||
@@ -426,11 +423,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) {
|
||||
userText, ok := call.Prompt[1].Content[0].(fantasy.TextPart)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, truncateRunes(longFirstUserText, 1000), userText.Text)
|
||||
return &fantasy.Response{
|
||||
Content: fantasy.ResponseContent{
|
||||
fantasy.TextContent{Text: "Refresh title"},
|
||||
},
|
||||
}, nil
|
||||
return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -455,11 +448,9 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T)
|
||||
}
|
||||
|
||||
model := &stubModel{
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: fantasy.ResponseContent{
|
||||
fantasy.TextContent{Text: "\"\""},
|
||||
},
|
||||
generateObjectFn: func(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: map[string]any{"title": "\"\""},
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 11,
|
||||
OutputTokens: 7,
|
||||
@@ -533,13 +524,17 @@ func Test_generateShortText_NormalizesQuotedOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
type stubModel struct {
|
||||
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
|
||||
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
|
||||
generateObjectFn func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error)
|
||||
}
|
||||
|
||||
func (m *stubModel) Generate(
|
||||
ctx context.Context,
|
||||
call fantasy.Call,
|
||||
) (*fantasy.Response, error) {
|
||||
if m.generateFn == nil {
|
||||
return nil, xerrors.New("generate not implemented")
|
||||
}
|
||||
return m.generateFn(ctx, call)
|
||||
}
|
||||
|
||||
@@ -550,11 +545,14 @@ func (*stubModel) Stream(
|
||||
return nil, xerrors.New("stream not implemented")
|
||||
}
|
||||
|
||||
func (*stubModel) GenerateObject(
|
||||
context.Context,
|
||||
fantasy.ObjectCall,
|
||||
func (m *stubModel) GenerateObject(
|
||||
ctx context.Context,
|
||||
call fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResponse, error) {
|
||||
return nil, xerrors.New("generate object not implemented")
|
||||
if m.generateObjectFn == nil {
|
||||
return nil, xerrors.New("generate object not implemented")
|
||||
}
|
||||
return m.generateObjectFn(ctx, call)
|
||||
}
|
||||
|
||||
func (*stubModel) StreamObject(
|
||||
|
||||
@@ -1012,8 +1012,10 @@ func TestAwaitSubagentCompletion(t *testing.T) {
|
||||
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
|
||||
|
||||
// Trap the fallback poll ticker to know when the
|
||||
// function has subscribed to pubsub and entered
|
||||
// its select loop.
|
||||
// function has entered the wait setup path. We still
|
||||
// need an explicit subscription handshake below because
|
||||
// the ticker can be created before SubscribeWithErr has
|
||||
// finished registering the listener.
|
||||
tickTrap := mClock.Trap().NewTicker("chatd", "subagent_poll")
|
||||
|
||||
type awaitResult struct {
|
||||
@@ -1029,19 +1031,47 @@ func TestAwaitSubagentCompletion(t *testing.T) {
|
||||
resultCh <- awaitResult{chat, report, err}
|
||||
}()
|
||||
|
||||
// Wait for the ticker to be created (confirms pubsub
|
||||
// subscription is set up and select loop entered).
|
||||
// Wait for the ticker to be created so the waiter has
|
||||
// entered its setup path, then subscribe our own probe on
|
||||
// the same channel. Because MemoryPubsub publishes only to
|
||||
// listeners already present at Publish time, waiting for
|
||||
// our probe to receive a message proves the waiter's
|
||||
// subscription is also registered before we assert on the
|
||||
// wake-up behavior.
|
||||
tickTrap.MustWait(ctx).MustRelease(ctx)
|
||||
tickTrap.Close()
|
||||
|
||||
// Transition child and publish. The pubsub notification
|
||||
// wakes the function without needing a clock advance.
|
||||
probeCh := make(chan struct{}, 1)
|
||||
cancelProbe, err := ps.SubscribeWithErr(
|
||||
coderdpubsub.ChatStreamNotifyChannel(child.ID),
|
||||
func(_ context.Context, _ []byte, _ error) {
|
||||
select {
|
||||
case probeCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer cancelProbe()
|
||||
|
||||
// Transition the child first, then publish once the
|
||||
// durable completion state is observable. Pubsub only
|
||||
// wakes the waiter; it does not guarantee the report is
|
||||
// visible in the same instant as the notification.
|
||||
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
|
||||
insertAssistantMessage(ctx, t, db, child.ID, model.ID, "pubsub result")
|
||||
_ = ps.Publish(
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
chat, report, done, err := server.checkSubagentCompletion(ctx, child.ID)
|
||||
require.NoError(c, err)
|
||||
assert.True(c, done)
|
||||
assert.Equal(c, child.ID, chat.ID)
|
||||
assert.Equal(c, "pubsub result", report)
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
require.NoError(t, ps.Publish(
|
||||
coderdpubsub.ChatStreamNotifyChannel(child.ID),
|
||||
[]byte("done"),
|
||||
)
|
||||
))
|
||||
testutil.RequireReceive(ctx, t, probeCh)
|
||||
|
||||
result := testutil.RequireReceive(ctx, t, resultCh)
|
||||
require.NoError(t, result.err)
|
||||
@@ -1049,6 +1079,27 @@ func TestAwaitSubagentCompletion(t *testing.T) {
|
||||
assert.Equal(t, "pubsub result", result.report)
|
||||
})
|
||||
|
||||
t.Run("AlreadyWaitingNoReport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := chatdTestContext(t)
|
||||
|
||||
parent, child := createParentChildChats(ctx, t, server, user, model)
|
||||
|
||||
// signalWake from CreateChat may trigger immediate processing.
|
||||
// Wait for it to settle, then set the terminal state we need.
|
||||
// This case should return immediately, so use the shared
|
||||
// real-clock server instead of a mock clock.
|
||||
server.inflight.Wait()
|
||||
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
|
||||
|
||||
gotChat, report, err := server.awaitSubagentCompletion(
|
||||
ctx, parent.ID, child.ID, 5*time.Second,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, child.ID, gotChat.ID)
|
||||
assert.Empty(t, report)
|
||||
})
|
||||
|
||||
t.Run("Timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -977,6 +977,7 @@ func TestWorker(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "integration-test",
|
||||
|
||||
+13
-12
@@ -12,18 +12,19 @@ import (
|
||||
)
|
||||
|
||||
type AIBridgeInterception struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
APIKeyID *string `json:"api_key_id"`
|
||||
Initiator MinimalUser `json:"initiator"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Client *string `json:"client"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
StartedAt time.Time `json:"started_at" format:"date-time"`
|
||||
EndedAt *time.Time `json:"ended_at" format:"date-time"`
|
||||
TokenUsages []AIBridgeTokenUsage `json:"token_usages"`
|
||||
UserPrompts []AIBridgeUserPrompt `json:"user_prompts"`
|
||||
ToolUsages []AIBridgeToolUsage `json:"tool_usages"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
APIKeyID *string `json:"api_key_id"`
|
||||
Initiator MinimalUser `json:"initiator"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Model string `json:"model"`
|
||||
Client *string `json:"client"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
StartedAt time.Time `json:"started_at" format:"date-time"`
|
||||
EndedAt *time.Time `json:"ended_at" format:"date-time"`
|
||||
TokenUsages []AIBridgeTokenUsage `json:"token_usages"`
|
||||
UserPrompts []AIBridgeUserPrompt `json:"user_prompts"`
|
||||
ToolUsages []AIBridgeToolUsage `json:"tool_usages"`
|
||||
}
|
||||
|
||||
type AIBridgeTokenUsage struct {
|
||||
|
||||
@@ -20,7 +20,7 @@ type NetworkedApplication interface {
|
||||
// Closer is used to gracefully tear down the application prior to stopping the tunnel.
|
||||
io.Closer
|
||||
// Start the NetworkedApplication, using the provided AgentConn to connect.
|
||||
Start(conn workspacesdk.AgentConn)
|
||||
Start(conn workspacesdk.AgentConn) error
|
||||
}
|
||||
|
||||
// WorkspaceStarter is used to create a start build of the workspace. It is an interface here because the CLI has lots
|
||||
@@ -63,6 +63,33 @@ const (
|
||||
maxState // used for testing
|
||||
)
|
||||
|
||||
func (s state) String() string {
|
||||
switch s {
|
||||
case stateInit:
|
||||
return "init"
|
||||
case exit:
|
||||
return "exit"
|
||||
case waitToStart:
|
||||
return "waitToStart"
|
||||
case waitForWorkspaceStarted:
|
||||
return "waitForWorkspaceStarted"
|
||||
case waitForAgent:
|
||||
return "waitForAgent"
|
||||
case establishTailnet:
|
||||
return "establishTailnet"
|
||||
case tailnetUp:
|
||||
return "tailnetUp"
|
||||
case applicationUp:
|
||||
return "applicationUp"
|
||||
case shutdownApplication:
|
||||
return "shutdownApplication"
|
||||
case shutdownTailnet:
|
||||
return "shutdownTailnet"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", s)
|
||||
}
|
||||
}
|
||||
|
||||
type Tunneler struct {
|
||||
config Config
|
||||
ctx context.Context
|
||||
@@ -179,10 +206,12 @@ func (t *Tunneler) eventLoop() {
|
||||
case e.tailnetUpdate != nil:
|
||||
t.handleTailnetUpdate(e.tailnetUpdate)
|
||||
}
|
||||
t.config.DebugLogger.Debug(t.ctx, "handled event", slog.F("state", t.state))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunneler) handleSignal() {
|
||||
t.config.DebugLogger.Debug(t.ctx, "got shutdown signal")
|
||||
switch t.state {
|
||||
case exit, shutdownTailnet, shutdownApplication:
|
||||
return
|
||||
@@ -313,6 +342,10 @@ func (*Tunneler) handleProvisionerJobLog(*codersdk.ProvisionerJobLog) {
|
||||
}
|
||||
|
||||
func (t *Tunneler) handleAgentUpdate(update *agentUpdate) {
|
||||
t.config.DebugLogger.Debug(t.ctx, "handling agent update",
|
||||
slog.F("state", t.state),
|
||||
slog.F("lifecycle", update.lifecycle),
|
||||
slog.F("agent_id", update.id))
|
||||
if t.state != waitForAgent {
|
||||
return
|
||||
}
|
||||
@@ -350,13 +383,140 @@ func (t *Tunneler) handleAgentUpdate(update *agentUpdate) {
|
||||
func (*Tunneler) handleAgentLog(*codersdk.WorkspaceAgentLog) {
|
||||
}
|
||||
|
||||
func (*Tunneler) handleAppUpdate(*networkedApplicationUpdate) {
|
||||
func (t *Tunneler) handleAppUpdate(update *networkedApplicationUpdate) {
|
||||
if update.up {
|
||||
t.config.DebugLogger.Debug(t.ctx, "networked application up")
|
||||
} else {
|
||||
// we already logged any error, so this is just debug to track the state change
|
||||
t.config.DebugLogger.Debug(t.ctx, "networked application down", slog.Error(update.err))
|
||||
}
|
||||
switch t.state {
|
||||
case exit:
|
||||
return
|
||||
case stateInit, waitToStart, waitForAgent, waitForWorkspaceStarted, establishTailnet:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: application update before we started it",
|
||||
slog.F("state", t.state), slog.F("app_up", update.up), slog.Error(update.err))
|
||||
return
|
||||
}
|
||||
if update.up {
|
||||
switch t.state {
|
||||
case tailnetUp:
|
||||
t.state = applicationUp
|
||||
return
|
||||
case applicationUp:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: application 'up' update when it is already up")
|
||||
return
|
||||
case shutdownApplication:
|
||||
// this means that we started shutting down while we were waiting for the goroutine that starts the
|
||||
// application to complete. We need to tear down the app.
|
||||
t.config.DebugLogger.Debug(t.ctx, "gracefully shutting down application after it started")
|
||||
t.wg.Add(1)
|
||||
go t.closeApp()
|
||||
return
|
||||
case shutdownTailnet:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: application 'up' update when we were tearing down tailnet")
|
||||
return
|
||||
}
|
||||
}
|
||||
switch t.state {
|
||||
case tailnetUp, applicationUp, shutdownApplication:
|
||||
t.state = shutdownTailnet
|
||||
t.wg.Add(1)
|
||||
go t.shutdownTailnet()
|
||||
return
|
||||
case shutdownTailnet:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: application 'down' update when we were tearing down tailnet")
|
||||
return
|
||||
}
|
||||
t.config.DebugLogger.Critical(t.ctx, "unhandled application update",
|
||||
slog.F("state", t.state), slog.F("app_up", update.up))
|
||||
}
|
||||
|
||||
func (*Tunneler) handleTailnetUpdate(*tailnetUpdate) {
|
||||
func (t *Tunneler) handleTailnetUpdate(update *tailnetUpdate) {
|
||||
switch t.state {
|
||||
case exit:
|
||||
return
|
||||
case stateInit, waitToStart, waitForAgent, waitForWorkspaceStarted:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: tailnet update before we started it",
|
||||
slog.F("state", t.state), slog.F("app_up", update.up), slog.Error(update.err))
|
||||
return
|
||||
}
|
||||
if update.up {
|
||||
t.config.DebugLogger.Debug(t.ctx, "got tailnet 'up' update", slog.F("state", t.state))
|
||||
switch t.state {
|
||||
case establishTailnet:
|
||||
t.agentConn = update.conn
|
||||
t.state = tailnetUp
|
||||
t.wg.Add(1)
|
||||
go t.startApp()
|
||||
return
|
||||
case shutdownTailnet:
|
||||
// this means we were notified to shut down while we were starting the tailnet. We need to tear it down.
|
||||
t.config.DebugLogger.Debug(t.ctx, "gracefully shutting down tailnet after it started")
|
||||
t.agentConn = update.conn
|
||||
t.wg.Add(1)
|
||||
go t.shutdownTailnet()
|
||||
return
|
||||
case tailnetUp:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: got tailnet 'up' update when it is already up")
|
||||
if update.conn != nil && update.conn != t.agentConn {
|
||||
// somehow we have two updates with different connections. Something very bad has happened so we are
|
||||
// going to just bail, rather than try to gracefully tear them both down.
|
||||
t.config.DebugLogger.Fatal(t.ctx, "unexpected: got two different connections")
|
||||
}
|
||||
return
|
||||
case shutdownApplication:
|
||||
t.config.DebugLogger.Error(t.ctx, "unexpected: got tailnet 'up' update when we expected application update")
|
||||
return
|
||||
}
|
||||
}
|
||||
t.config.DebugLogger.Debug(t.ctx, "got tailnet 'down' update", slog.F("state", t.state))
|
||||
switch t.state {
|
||||
case establishTailnet, shutdownTailnet:
|
||||
// Either we failed to establish, or we successfully shut down. In the former case, the error has already been
|
||||
// logged. Nothing else to do now that tailnet is down, since it implies the application is also down.
|
||||
t.cancel()
|
||||
t.state = exit
|
||||
return
|
||||
case tailnetUp:
|
||||
t.config.DebugLogger.Error(t.ctx,
|
||||
"unexpected: got tailnet 'down' update when we were starting the application")
|
||||
return
|
||||
case shutdownApplication:
|
||||
t.config.DebugLogger.Error(t.ctx,
|
||||
"unexpected: got tailnet 'down' update when we were stopping the application")
|
||||
return
|
||||
}
|
||||
t.config.DebugLogger.Critical(t.ctx, "unhandled tailnet update",
|
||||
slog.F("state", t.state), slog.F("app_up", update.up))
|
||||
}
|
||||
|
||||
func (t *Tunneler) startApp() {
|
||||
t.config.DebugLogger.Debug(t.ctx, "starting networked application")
|
||||
defer t.wg.Done()
|
||||
err := t.config.App.Start(t.agentConn)
|
||||
if err != nil {
|
||||
t.config.DebugLogger.Error(t.ctx, "failed to start application", slog.Error(err))
|
||||
if t.config.LogWriter != nil {
|
||||
_, _ = fmt.Fprintf(t.config.LogWriter, "failed to start: %s", err.Error())
|
||||
}
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
t.config.DebugLogger.Info(t.ctx,
|
||||
"context expired before sending event after failed network application start")
|
||||
case t.events <- tunnelerEvent{appUpdate: &networkedApplicationUpdate{up: false, err: err}}:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
t.config.DebugLogger.Info(t.ctx, "context expired before sending network application start update")
|
||||
case t.events <- tunnelerEvent{appUpdate: &networkedApplicationUpdate{up: true}}:
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunneler) closeApp() {
|
||||
t.config.DebugLogger.Info(t.ctx, "closing networked application")
|
||||
defer t.wg.Done()
|
||||
err := t.config.App.Close()
|
||||
if err != nil {
|
||||
@@ -370,6 +530,7 @@ func (t *Tunneler) closeApp() {
|
||||
}
|
||||
|
||||
func (t *Tunneler) startWorkspace() {
|
||||
t.config.DebugLogger.Info(t.ctx, "starting workspace")
|
||||
defer t.wg.Done()
|
||||
err := t.config.WorkspaceStarter.StartWorkspace()
|
||||
if err != nil {
|
||||
@@ -382,10 +543,12 @@ func (t *Tunneler) startWorkspace() {
|
||||
t.config.DebugLogger.Info(t.ctx, "context expired before sending signal after failed workspace start")
|
||||
case t.events <- tunnelerEvent{appUpdate: &networkedApplicationUpdate{up: false}}:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tunneler) connectTailnet(id uuid.UUID) {
|
||||
t.config.DebugLogger.Info(t.ctx, "connecting tailnet")
|
||||
defer t.wg.Done()
|
||||
conn, err := t.client.DialAgent(t.ctx, id, &workspacesdk.DialAgentOptions{
|
||||
Logger: t.config.DebugLogger.Named("dialer"),
|
||||
@@ -400,6 +563,7 @@ func (t *Tunneler) connectTailnet(id uuid.UUID) {
|
||||
t.config.DebugLogger.Info(t.ctx, "context expired before sending event after failed agent dial")
|
||||
case t.events <- tunnelerEvent{tailnetUpdate: &tailnetUpdate{up: false, err: err}}:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
@@ -408,16 +572,16 @@ func (t *Tunneler) connectTailnet(id uuid.UUID) {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Restore this func when we implement tearing down the tailnet
|
||||
// func (t *Tunneler) shutdownTailnet() {
|
||||
// defer t.wg.Done()
|
||||
// err := t.agentConn.Close()
|
||||
// if err != nil {
|
||||
// t.config.DebugLogger.Error(t.ctx, "failed to close agent connection", slog.Error(err))
|
||||
// }
|
||||
// select {
|
||||
// case <-t.ctx.Done():
|
||||
// t.config.DebugLogger.Debug(t.ctx, "context expired before sending event after shutting down tailnet")
|
||||
// case t.events <- tunnelerEvent{tailnetUpdate: &tailnetUpdate{up: false, err: err}}:
|
||||
// }
|
||||
//}
|
||||
func (t *Tunneler) shutdownTailnet() {
|
||||
t.config.DebugLogger.Info(t.ctx, "shutting down tailnet")
|
||||
defer t.wg.Done()
|
||||
err := t.agentConn.Close()
|
||||
if err != nil {
|
||||
t.config.DebugLogger.Error(t.ctx, "failed to close agent connection", slog.Error(err))
|
||||
}
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
t.config.DebugLogger.Debug(t.ctx, "context expired before sending event after shutting down tailnet")
|
||||
case t.events <- tunnelerEvent{tailnetUpdate: &tailnetUpdate{up: false, err: err}}:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func coverUpdate(t *testing.T, workspaceID uuid.UUID, noAutostart bool, noWaitFo
|
||||
client: fClient,
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
App: fakeApp{},
|
||||
App: &fakeApp{},
|
||||
WorkspaceStarter: &fakeWorkspaceStarter{},
|
||||
AgentName: "test",
|
||||
NoAutostart: noAutostart,
|
||||
@@ -94,7 +94,7 @@ func TestBuildUpdatesStoppedWorkspace(t *testing.T) {
|
||||
uut := &Tunneler{
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
App: fakeApp{},
|
||||
App: &fakeApp{},
|
||||
WorkspaceStarter: &fWorkspaceStarter,
|
||||
AgentName: "test",
|
||||
DebugLogger: logger.Named("tunneler"),
|
||||
@@ -145,7 +145,7 @@ func TestBuildUpdatesNewBuildWhileWaiting(t *testing.T) {
|
||||
uut := &Tunneler{
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
App: fakeApp{},
|
||||
App: &fakeApp{},
|
||||
WorkspaceStarter: &fWorkspaceStarter,
|
||||
AgentName: "test",
|
||||
DebugLogger: logger.Named("tunneler"),
|
||||
@@ -182,7 +182,7 @@ func TestBuildUpdatesBadJobs(t *testing.T) {
|
||||
uut := &Tunneler{
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
App: fakeApp{},
|
||||
App: &fakeApp{},
|
||||
WorkspaceStarter: &fWorkspaceStarter,
|
||||
AgentName: "test",
|
||||
DebugLogger: logger.Named("tunneler"),
|
||||
@@ -220,7 +220,7 @@ func TestBuildUpdatesNoAutostart(t *testing.T) {
|
||||
uut := &Tunneler{
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
App: fakeApp{},
|
||||
App: &fakeApp{},
|
||||
WorkspaceStarter: &fWorkspaceStarter,
|
||||
AgentName: "test",
|
||||
NoAutostart: true,
|
||||
@@ -332,6 +332,253 @@ func TestAgentUpdateNoWait(t *testing.T) {
|
||||
require.True(t, event.tailnetUpdate.up)
|
||||
}
|
||||
|
||||
func TestAppUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
up bool
|
||||
initState, expected state
|
||||
expectCloseApp, expectShutdownTailnet bool
|
||||
}{
|
||||
{
|
||||
name: "mainline_up",
|
||||
up: true,
|
||||
initState: tailnetUp,
|
||||
expected: applicationUp,
|
||||
},
|
||||
{
|
||||
name: "mainline_down",
|
||||
up: false,
|
||||
initState: applicationUp,
|
||||
expected: shutdownTailnet,
|
||||
expectShutdownTailnet: true,
|
||||
},
|
||||
{
|
||||
name: "failed_app_start",
|
||||
up: false,
|
||||
initState: tailnetUp,
|
||||
expected: shutdownTailnet,
|
||||
expectShutdownTailnet: true,
|
||||
},
|
||||
{
|
||||
name: "graceful_shutdown_while_starting",
|
||||
up: true,
|
||||
initState: shutdownApplication,
|
||||
expected: shutdownApplication,
|
||||
expectCloseApp: true,
|
||||
},
|
||||
{
|
||||
name: "graceful_shutdown_of_app",
|
||||
up: false,
|
||||
initState: shutdownApplication,
|
||||
expected: shutdownTailnet,
|
||||
expectShutdownTailnet: true,
|
||||
},
|
||||
// note that we don't expect initState: applicationUp with an up update, so only five valid cases
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
workspaceID := uuid.UUID{1}
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mAgentConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
fApp := &fakeApp{}
|
||||
|
||||
testCtx := testutil.Context(t, testutil.WaitShort)
|
||||
ctx, cancel := context.WithCancel(testCtx)
|
||||
uut := &Tunneler{
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
AgentName: "test",
|
||||
DebugLogger: logger.Named("tunneler"),
|
||||
App: fApp,
|
||||
},
|
||||
events: make(chan tunnelerEvent),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
state: tc.initState,
|
||||
agentConn: mAgentConn,
|
||||
}
|
||||
if tc.expectShutdownTailnet {
|
||||
mAgentConn.EXPECT().Close().Return(nil).Times(1)
|
||||
}
|
||||
|
||||
uut.handleAppUpdate(&networkedApplicationUpdate{up: tc.up})
|
||||
require.Equal(t, tc.expected, uut.state)
|
||||
cancel() // so that any goroutines can complete without an event loop
|
||||
waitForGoroutines(testCtx, t, uut)
|
||||
require.Equal(t, tc.expectCloseApp, fApp.closed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailnetUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
up bool
|
||||
initState, expected state
|
||||
expectStartApp, expectShutdownTailnet bool
|
||||
}{
|
||||
{
|
||||
name: "mainline_up",
|
||||
up: true,
|
||||
initState: establishTailnet,
|
||||
expected: tailnetUp,
|
||||
expectStartApp: true,
|
||||
},
|
||||
{
|
||||
name: "mainline_down",
|
||||
up: false,
|
||||
initState: shutdownTailnet,
|
||||
expected: exit,
|
||||
},
|
||||
{
|
||||
name: "failed_tailnet_start",
|
||||
up: false,
|
||||
initState: establishTailnet,
|
||||
expected: exit,
|
||||
},
|
||||
{
|
||||
name: "graceful_shutdown_while_starting",
|
||||
up: true,
|
||||
initState: shutdownTailnet,
|
||||
expected: shutdownTailnet,
|
||||
expectShutdownTailnet: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
workspaceID := uuid.UUID{1}
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mAgentConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
fApp := &fakeApp{}
|
||||
|
||||
testCtx := testutil.Context(t, testutil.WaitShort)
|
||||
ctx, cancel := context.WithCancel(testCtx)
|
||||
uut := &Tunneler{
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
AgentName: "test",
|
||||
DebugLogger: logger.Named("tunneler"),
|
||||
App: fApp,
|
||||
},
|
||||
events: make(chan tunnelerEvent),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
state: tc.initState,
|
||||
}
|
||||
if tc.expectShutdownTailnet {
|
||||
mAgentConn.EXPECT().Close().Return(nil).Times(1)
|
||||
}
|
||||
|
||||
update := &tailnetUpdate{up: tc.up}
|
||||
if tc.up {
|
||||
update.conn = mAgentConn
|
||||
}
|
||||
uut.handleTailnetUpdate(update)
|
||||
require.Equal(t, tc.expected, uut.state)
|
||||
cancel() // so that any goroutines can complete without an event loop
|
||||
waitForGoroutines(testCtx, t, uut)
|
||||
require.Equal(t, tc.expectStartApp, fApp.started)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunneler_EventLoop_Signal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workspaceID := uuid.UUID{1}
|
||||
agentID := uuid.UUID{2}
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mAgentConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
fApp := &fakeApp{
|
||||
starts: make(chan appStartRequest),
|
||||
closes: make(chan errorResult),
|
||||
}
|
||||
fClient := &fakeClient{
|
||||
dials: make(chan dialRequest),
|
||||
}
|
||||
|
||||
testCtx := testutil.Context(t, testutil.WaitShort)
|
||||
ctx, cancel := context.WithCancel(testCtx)
|
||||
uut := &Tunneler{
|
||||
client: fClient,
|
||||
config: Config{
|
||||
WorkspaceID: workspaceID,
|
||||
AgentName: "test",
|
||||
DebugLogger: logger.Named("tunneler"),
|
||||
App: fApp,
|
||||
},
|
||||
events: make(chan tunnelerEvent),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
state: stateInit,
|
||||
}
|
||||
uut.wg.Add(1)
|
||||
go uut.eventLoop()
|
||||
|
||||
testutil.RequireSend(testCtx, t, uut.events, tunnelerEvent{
|
||||
buildUpdate: &buildUpdate{
|
||||
transition: codersdk.WorkspaceTransitionStart,
|
||||
jobStatus: codersdk.ProvisionerJobPending,
|
||||
},
|
||||
})
|
||||
testutil.RequireSend(testCtx, t, uut.events, tunnelerEvent{
|
||||
buildUpdate: &buildUpdate{
|
||||
transition: codersdk.WorkspaceTransitionStart,
|
||||
jobStatus: codersdk.ProvisionerJobRunning,
|
||||
},
|
||||
})
|
||||
testutil.RequireSend(testCtx, t, uut.events, tunnelerEvent{
|
||||
buildUpdate: &buildUpdate{
|
||||
transition: codersdk.WorkspaceTransitionStart,
|
||||
jobStatus: codersdk.ProvisionerJobSucceeded,
|
||||
},
|
||||
})
|
||||
testutil.RequireSend(testCtx, t, uut.events, tunnelerEvent{
|
||||
agentUpdate: &agentUpdate{
|
||||
lifecycle: codersdk.WorkspaceAgentLifecycleReady,
|
||||
id: agentID,
|
||||
},
|
||||
})
|
||||
|
||||
// Workspace started, agent ready. Should connect the tailnet.
|
||||
tailnetDial := testutil.RequireReceive(testCtx, t, fClient.dials)
|
||||
testutil.RequireSend(testCtx, t, tailnetDial.result, dialResult{conn: mAgentConn})
|
||||
|
||||
// Tailnet up, should start App
|
||||
appStart := testutil.RequireReceive(testCtx, t, fApp.starts)
|
||||
require.Equal(t, mAgentConn, appStart.conn)
|
||||
testutil.RequireSend(testCtx, t, appStart.result, nil)
|
||||
|
||||
connClosed := make(chan struct{})
|
||||
mAgentConn.EXPECT().Close().Times(1).Do(func() {
|
||||
close(connClosed)
|
||||
}).Return(nil)
|
||||
|
||||
testutil.RequireSend(testCtx, t, uut.events, tunnelerEvent{
|
||||
shutdownSignal: &shutdownSignal{},
|
||||
})
|
||||
|
||||
closeReq := testutil.RequireReceive(testCtx, t, fApp.closes)
|
||||
testutil.RequireSend(testCtx, t, closeReq.result, nil)
|
||||
|
||||
// next tailnet closes
|
||||
_ = testutil.TryReceive(testCtx, t, connClosed)
|
||||
|
||||
// should cancel the loop and be at exit
|
||||
waitForGoroutines(testCtx, t, uut)
|
||||
require.Equal(t, exit, uut.state)
|
||||
}
|
||||
|
||||
func waitForGoroutines(ctx context.Context, t *testing.T, tunneler *Tunneler) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -341,29 +588,87 @@ func waitForGoroutines(ctx context.Context, t *testing.T, tunneler *Tunneler) {
|
||||
_ = testutil.TryReceive(ctx, t, done)
|
||||
}
|
||||
|
||||
type errorResult struct {
|
||||
result chan error
|
||||
}
|
||||
|
||||
type fakeWorkspaceStarter struct {
|
||||
starts chan errorResult
|
||||
started bool
|
||||
}
|
||||
|
||||
func (f *fakeWorkspaceStarter) StartWorkspace() error {
|
||||
f.started = true
|
||||
return nil
|
||||
if f.starts == nil {
|
||||
f.started = true
|
||||
return nil
|
||||
}
|
||||
result := make(chan error)
|
||||
f.starts <- errorResult{result: result}
|
||||
return <-result
|
||||
}
|
||||
|
||||
type fakeApp struct{}
|
||||
|
||||
func (fakeApp) Close() error {
|
||||
return nil
|
||||
type appStartRequest struct {
|
||||
conn workspacesdk.AgentConn
|
||||
result chan error
|
||||
}
|
||||
|
||||
func (fakeApp) Start(workspacesdk.AgentConn) {}
|
||||
type fakeApp struct {
|
||||
starts chan appStartRequest
|
||||
closes chan errorResult
|
||||
closed bool
|
||||
started bool
|
||||
}
|
||||
|
||||
func (f *fakeApp) Close() error {
|
||||
if f.closes == nil {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
result := make(chan error)
|
||||
f.closes <- errorResult{result: result}
|
||||
return <-result
|
||||
}
|
||||
|
||||
func (f *fakeApp) Start(conn workspacesdk.AgentConn) error {
|
||||
if f.starts == nil {
|
||||
f.started = true
|
||||
return nil
|
||||
}
|
||||
result := make(chan error)
|
||||
f.starts <- appStartRequest{result: result, conn: conn}
|
||||
return <-result
|
||||
}
|
||||
|
||||
type dialRequest struct {
|
||||
id uuid.UUID
|
||||
result chan dialResult
|
||||
}
|
||||
|
||||
type dialResult struct {
|
||||
conn workspacesdk.AgentConn
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
// async:
|
||||
dials chan dialRequest
|
||||
|
||||
// sync:
|
||||
conn workspacesdk.AgentConn
|
||||
dialed bool
|
||||
}
|
||||
|
||||
func (f *fakeClient) DialAgent(context.Context, uuid.UUID, *workspacesdk.DialAgentOptions) (workspacesdk.AgentConn, error) {
|
||||
f.dialed = true
|
||||
return f.conn, nil
|
||||
func (f *fakeClient) DialAgent(
|
||||
_ context.Context, id uuid.UUID, _ *workspacesdk.DialAgentOptions,
|
||||
) (
|
||||
workspacesdk.AgentConn, error,
|
||||
) {
|
||||
if f.dials == nil {
|
||||
f.dialed = true
|
||||
return f.conn, nil
|
||||
}
|
||||
results := make(chan dialResult)
|
||||
f.dials <- dialRequest{id: id, result: results}
|
||||
result := <-results
|
||||
return result.conn, result.err
|
||||
}
|
||||
|
||||
@@ -65,9 +65,12 @@ Once the server restarts with the experiment enabled:
|
||||
1. Navigate to the **Agents** page in the Coder dashboard.
|
||||
1. Open **Admin** settings and configure at least one LLM provider and model.
|
||||
See [Models](./models.md) for detailed setup instructions.
|
||||
1. Grant the **Coder Agents User** role to users who need to create chats.
|
||||
Go to **Admin** > **Users**, click the roles icon next to each user,
|
||||
and enable **Coder Agents User**.
|
||||
1. Grant the **Coder Agents User** role to existing users who need to create
|
||||
chats. New users receive the role automatically. For existing users, go to
|
||||
**Admin** > **Users**, click the roles icon next to each user, and enable
|
||||
**Coder Agents User**. See
|
||||
[Grant Coder Agents User](./getting-started.md#step-3-grant-coder-agents-user)
|
||||
for a bulk CLI option.
|
||||
1. Developers can then start a new chat from the Agents page.
|
||||
|
||||
## Licensing and availability
|
||||
|
||||
@@ -24,8 +24,9 @@ Before you begin, confirm the following:
|
||||
for the agent to select when provisioning workspaces.
|
||||
- **Admin access** to the Coder deployment for enabling the experiment and
|
||||
configuring providers.
|
||||
- **Coder Agents User role** assigned to each user who needs to interact with Coder Agents.
|
||||
Owners can assign this from **Admin** > **Users**. See
|
||||
- **Coder Agents User role** is automatically assigned to new users when the
|
||||
`agents` experiment is enabled. For existing users, owners can assign it from
|
||||
**Admin** > **Users**. See
|
||||
[Grant Coder Agents User](#step-3-grant-coder-agents-user) below.
|
||||
|
||||
## Step 1: Enable the experiment
|
||||
@@ -74,10 +75,20 @@ Detailed instructions for each provider and model option are in the
|
||||
|
||||
## Step 3: Grant Coder Agents User
|
||||
|
||||
The **Coder Agents User** role controls which users can interact with Coder Agents.
|
||||
Members do not have Coder Agents User by default.
|
||||
The **Coder Agents User** role controls which users can interact with
|
||||
Coder Agents.
|
||||
|
||||
Owners always have full access and do not need the role.
|
||||
### New users
|
||||
|
||||
When the `agents` experiment is enabled, new users are automatically
|
||||
assigned the **Coder Agents User** role at account creation. No admin
|
||||
action is required.
|
||||
|
||||
### Existing users
|
||||
|
||||
Users who were created before the experiment was enabled do not receive
|
||||
the role automatically. Owners can assign it from the dashboard or in
|
||||
bulk via the CLI.
|
||||
|
||||
**Dashboard (individual):**
|
||||
|
||||
@@ -85,19 +96,12 @@ Owners always have full access and do not need the role.
|
||||
1. Click the roles icon next to the user you want to grant access to.
|
||||
1. Enable the **Coder Agents User** role and save.
|
||||
|
||||
Repeat for each user who needs access.
|
||||
|
||||
> [!NOTE]
|
||||
> Users who created conversations before this role was introduced are
|
||||
> automatically granted the role during upgrade.
|
||||
|
||||
**CLI (bulk):**
|
||||
|
||||
You can also grant the role via CLI. For example, to grant the role to
|
||||
all active users at once:
|
||||
To grant the role to all active users at once:
|
||||
|
||||
```sh
|
||||
coder users list -o json \
|
||||
coder users list --status active -o json \
|
||||
| jq -r '.[].username' \
|
||||
| while read u; do
|
||||
coder users edit-roles "$u" \
|
||||
@@ -107,6 +111,12 @@ coder users list -o json \
|
||||
done
|
||||
```
|
||||
|
||||
Owners always have full access and do not need the role.
|
||||
|
||||
> [!NOTE]
|
||||
> Users who created conversations before this role was introduced are
|
||||
> automatically granted the role during upgrade.
|
||||
|
||||
## Step 4: Start your first Coder Agent
|
||||
|
||||
1. Go to the **Agents** page in the Coder dashboard.
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
# Claude Code
|
||||
|
||||
## Configuration
|
||||
Claude Code can be configured using environment variables. All modes require a **[Coder session token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)** for authentication with AI Bridge.
|
||||
|
||||
Claude Code can be configured using environment variables.
|
||||
## Centralized API Key
|
||||
|
||||
* **Base URL**: `ANTHROPIC_BASE_URL` should point to `https://coder.example.com/api/v2/aibridge/anthropic`
|
||||
* **Auth Token**: `ANTHROPIC_AUTH_TOKEN` should be your [Coder session token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself).
|
||||
```bash
|
||||
# AI Bridge base URL.
|
||||
export ANTHROPIC_BASE_URL="<your-deployment-url>/api/v2/aibridge/anthropic"
|
||||
|
||||
### Pre-configuring in Templates
|
||||
# Your Coder session token, used for authentication with AI Bridge.
|
||||
export ANTHROPIC_AUTH_TOKEN="<your-coder-session-token>"
|
||||
```
|
||||
|
||||
## BYOK (Personal API Key)
|
||||
|
||||
```bash
|
||||
# AI Bridge base URL.
|
||||
export ANTHROPIC_BASE_URL="<your-deployment-url>/api/v2/aibridge/anthropic"
|
||||
|
||||
# Your personal Anthropic API key, forwarded to Anthropic.
|
||||
export ANTHROPIC_API_KEY="<your-anthropic-api-key>"
|
||||
|
||||
# Your Coder session token, used for authentication with AI Bridge.
|
||||
export ANTHROPIC_CUSTOM_HEADERS="X-Coder-AI-Governance-Token: <your-coder-session-token>"
|
||||
|
||||
# Ensure no auth token is set so Claude Code uses the API key instead.
|
||||
unset ANTHROPIC_AUTH_TOKEN
|
||||
```
|
||||
|
||||
## BYOK (Claude Subscription)
|
||||
|
||||
```bash
|
||||
# AI Bridge base URL.
|
||||
export ANTHROPIC_BASE_URL="<your-deployment-url>/api/v2/aibridge/anthropic"
|
||||
|
||||
# Your Coder session token, used for authentication with AI Bridge.
|
||||
export ANTHROPIC_CUSTOM_HEADERS="X-Coder-AI-Governance-Token: <your-coder-session-token>"
|
||||
|
||||
# Ensure no auth token is set so Claude Code uses subscription login instead.
|
||||
unset ANTHROPIC_AUTH_TOKEN
|
||||
```
|
||||
|
||||
When you run Claude Code, it will prompt you to log in with your Anthropic
|
||||
account.
|
||||
|
||||
## Pre-configuring in Templates
|
||||
|
||||
Template admins can pre-configure Claude Code for a seamless experience. Admins can automatically inject the user's Coder session token and the AI Bridge base URL into the workspace environment.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Codex CLI can be configured to use AI Bridge by setting up a custom model provider.
|
||||
|
||||
## Configuration
|
||||
## Centralized API Key
|
||||
|
||||
To configure Codex CLI to use AI Bridge, set the following configuration options in your Codex configuration file (e.g., `~/.codex/config.toml`):
|
||||
|
||||
@@ -16,9 +16,73 @@ env_key = "OPENAI_API_KEY"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
Run Codex as usual. It will automatically use the `aibridge` model provider from your configuration:
|
||||
To authenticate with AI Bridge, get your **[Coder session token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)** and set it in your environment:
|
||||
|
||||
If configuring within a Coder workspace, you can also use the [Codex CLI](https://registry.coder.com/modules/coder-labs/codex) module and set the following variables:
|
||||
```bash
|
||||
export OPENAI_API_KEY="<your-coder-session-token>"
|
||||
```
|
||||
|
||||
Run Codex as usual. It will automatically use the `aibridge` model provider from your configuration.
|
||||
|
||||
## BYOK (Personal API Key)
|
||||
|
||||
Add the following to your Codex configuration file (e.g., `~/.codex/config.toml`):
|
||||
|
||||
```toml
|
||||
model_provider = "aibridge"
|
||||
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "<your-deployment-url>/api/v2/aibridge/openai/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
env_http_headers = { "X-Coder-AI-Governance-Token" = "CODER_SESSION_TOKEN" }
|
||||
```
|
||||
|
||||
Set both environment variables:
|
||||
|
||||
```bash
|
||||
# Your personal OpenAI API key, forwarded to OpenAI.
|
||||
export OPENAI_API_KEY="<your-openai-api-key>"
|
||||
|
||||
# Your Coder session token, used for authentication with AI Bridge.
|
||||
export CODER_SESSION_TOKEN="<your-coder-session-token>"
|
||||
```
|
||||
|
||||
## BYOK (ChatGPT Subscription)
|
||||
|
||||
Add the following to your Codex configuration file (e.g., `~/.codex/config.toml`):
|
||||
|
||||
```toml
|
||||
model_provider = "aibridge"
|
||||
|
||||
[model_providers.aibridge]
|
||||
name = "AI Bridge"
|
||||
base_url = "<your-deployment-url>/api/v2/aibridge/chatgpt/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
env_http_headers = { "X-Coder-AI-Governance-Token" = "CODER_SESSION_TOKEN" }
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `base_url` uses `/aibridge/chatgpt/v1` instead of `/aibridge/openai/v1` to route requests through the ChatGPT provider.
|
||||
|
||||
Set your Coder session token and ensure `OPENAI_API_KEY` is not set:
|
||||
|
||||
```bash
|
||||
# Your Coder session token, used for authentication with AI Bridge.
|
||||
export CODER_SESSION_TOKEN="<your-coder-session-token>"
|
||||
|
||||
# Ensure no OpenAI API key is set so Codex uses ChatGPT login instead.
|
||||
unset OPENAI_API_KEY
|
||||
```
|
||||
|
||||
When you run Codex, it will prompt you to log in with your ChatGPT account.
|
||||
|
||||
## Pre-configuring in Templates
|
||||
|
||||
If configuring within a Coder workspace, you can use the
|
||||
[Codex CLI](https://registry.coder.com/modules/coder-labs/codex) module:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
@@ -30,12 +94,4 @@ module "codex" {
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with AI Bridge, get your **[Coder session token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)** and set it in your environment:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="<your-coder-session-token>"
|
||||
```
|
||||
|
||||
**References:** [Codex CLI Configuration](https://developers.openai.com/codex/config-advanced)
|
||||
|
||||
@@ -43,6 +43,29 @@ export ANTHROPIC_BASE_URL="https://coder.example.com/api/v2/aibridge/anthropic"
|
||||
|
||||
Alternatively, [generate a long-lived API token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself) via the Coder dashboard.
|
||||
|
||||
## Bring Your Own Key (BYOK)
|
||||
|
||||
In addition to centralized key management, AI Bridge supports **Bring Your
|
||||
Own Key** (BYOK) mode. Users can provide their own LLM API keys or use
|
||||
provider subscriptions (such as Claude Pro/Max or ChatGPT Plus/Pro) while
|
||||
AI Bridge continues to provide observability and governance.
|
||||
|
||||

|
||||
|
||||
In BYOK mode, users need two credentials:
|
||||
|
||||
- A **Coder session token** to authenticate with AI Bridge.
|
||||
- Their **own LLM credential** (personal API key or subscription token) which AI Bridge forwards
|
||||
to the upstream provider.
|
||||
|
||||
BYOK and centralized modes can be used together. When a user provides
|
||||
their own credential, AI Bridge forwards it directly. When no user
|
||||
credential is present, AI Bridge falls back to the admin-configured
|
||||
provider key. This lets organizations offer centralized keys as a default
|
||||
while allowing individual users to bring their own.
|
||||
|
||||
See individual client pages for configuration details.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The table below shows tested AI clients and their compatibility with AI Bridge.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
Generated
+1
@@ -80,6 +80,7 @@ curl -X GET http://coder-server:8080/api/v2/aibridge/interceptions \
|
||||
},
|
||||
"model": "string",
|
||||
"provider": "string",
|
||||
"provider_name": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"token_usages": [
|
||||
{
|
||||
|
||||
Generated
+3
@@ -498,6 +498,7 @@
|
||||
},
|
||||
"model": "string",
|
||||
"provider": "string",
|
||||
"provider_name": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"token_usages": [
|
||||
{
|
||||
@@ -559,6 +560,7 @@
|
||||
| » `[any property]` | any | false | | |
|
||||
| `model` | string | false | | |
|
||||
| `provider` | string | false | | |
|
||||
| `provider_name` | string | false | | |
|
||||
| `started_at` | string | false | | |
|
||||
| `token_usages` | array of [codersdk.AIBridgeTokenUsage](#codersdkaibridgetokenusage) | false | | |
|
||||
| `tool_usages` | array of [codersdk.AIBridgeToolUsage](#codersdkaibridgetoolusage) | false | | |
|
||||
@@ -587,6 +589,7 @@
|
||||
},
|
||||
"model": "string",
|
||||
"provider": "string",
|
||||
"provider_name": "string",
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"token_usages": [
|
||||
{
|
||||
|
||||
@@ -123,7 +123,7 @@ module "personalize" {
|
||||
|
||||
module "code-server" {
|
||||
source = "dev.registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = local.repo_dir
|
||||
auto_install_extensions = true
|
||||
|
||||
@@ -8,7 +8,7 @@ RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.edge.kernel.org/debian
|
||||
RUN apt-get update && apt-get install -y libssl-dev openssl pkg-config build-essential
|
||||
RUN cargo install jj-cli typos-cli watchexec-cli
|
||||
|
||||
FROM ubuntu:jammy@sha256:ce4a593b4e323dcc3dd728e397e0a866a1bf516a1b7c31d6aa06991baec4f2e0 AS go
|
||||
FROM ubuntu:jammy@sha256:5e5b128eb4ff35ee52687c20d081dcc15b8cb55e113247683f435224fc58b956 AS go
|
||||
|
||||
# Install Go manually, so that we can control the version
|
||||
ARG GO_VERSION=1.25.8
|
||||
@@ -83,7 +83,7 @@ RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/d
|
||||
unzip protoc.zip && \
|
||||
rm protoc.zip
|
||||
|
||||
FROM ubuntu:jammy@sha256:ce4a593b4e323dcc3dd728e397e0a866a1bf516a1b7c31d6aa06991baec4f2e0
|
||||
FROM ubuntu:jammy@sha256:5e5b128eb4ff35ee52687c20d081dcc15b8cb55e113247683f435224fc58b956
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ module "mux" {
|
||||
module "code-server" {
|
||||
count = contains(jsondecode(data.coder_parameter.ide_choices.value), "code-server") ? data.coder_workspace.me.start_count : 0
|
||||
source = "dev.registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = local.repo_dir
|
||||
auto_install_extensions = true
|
||||
@@ -416,7 +416,7 @@ module "vscode-web" {
|
||||
module "jetbrains" {
|
||||
count = contains(jsondecode(data.coder_parameter.ide_choices.value), "jetbrains") ? data.coder_workspace.me.start_count : 0
|
||||
source = "dev.registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.dev.id
|
||||
agent_name = "dev"
|
||||
folder = local.repo_dir
|
||||
|
||||
@@ -35,7 +35,10 @@ var (
|
||||
func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
logger := s.logger.With(slog.F("path", r.URL.Path))
|
||||
logger := s.logger.With(
|
||||
slog.F("method", r.Method),
|
||||
slog.F("path", r.URL.Path),
|
||||
)
|
||||
|
||||
// Extract and strip proxy request ID for cross-service log
|
||||
// correlation. Absent for direct requests not routed through
|
||||
@@ -55,7 +58,13 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
key := strings.TrimSpace(agplaibridge.ExtractAuthToken(r.Header))
|
||||
if key == "" {
|
||||
logger.Warn(ctx, "no auth key provided")
|
||||
// Some clients (e.g. Claude) send a HEAD request
|
||||
// without credentials to check connectivity.
|
||||
if r.Method == http.MethodHead {
|
||||
logger.Info(ctx, "unauthenticated HEAD request")
|
||||
} else {
|
||||
logger.Warn(ctx, "no auth key provided")
|
||||
}
|
||||
http.Error(rw, ErrNoAuthKey.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type RecordInterceptionRequest struct {
|
||||
UserAgent string `protobuf:"bytes,9,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"`
|
||||
CorrelatingToolCallId *string `protobuf:"bytes,10,opt,name=correlating_tool_call_id,json=correlatingToolCallId,proto3,oneof" json:"correlating_tool_call_id,omitempty"`
|
||||
ClientSessionId *string `protobuf:"bytes,11,opt,name=client_session_id,json=clientSessionId,proto3,oneof" json:"client_session_id,omitempty"`
|
||||
ProviderName string `protobuf:"bytes,12,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RecordInterceptionRequest) Reset() {
|
||||
@@ -149,6 +150,13 @@ func (x *RecordInterceptionRequest) GetClientSessionId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RecordInterceptionRequest) GetProviderName() string {
|
||||
if x != nil {
|
||||
return x.ProviderName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RecordInterceptionResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1193,7 +1201,7 @@ var file_enterprise_aibridged_proto_aibridged_proto_rawDesc = []byte{
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f,
|
||||
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f,
|
||||
0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22,
|
||||
0xd1, 0x04, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63,
|
||||
0xf6, 0x04, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63,
|
||||
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a,
|
||||
0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a,
|
||||
0x0c, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
|
||||
@@ -1221,121 +1229,67 @@ var file_enterprise_aibridged_proto_aibridged_proto_rawDesc = []byte{
|
||||
0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x11,
|
||||
0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x63, 0x6c, 0x69, 0x65, 0x6e,
|
||||
0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x1a, 0x51, 0x0a,
|
||||
0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
|
||||
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
|
||||
0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
|
||||
0x42, 0x1b, 0x0a, 0x19, 0x5f, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6e, 0x67,
|
||||
0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x42, 0x14, 0x0a,
|
||||
0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72,
|
||||
0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||
0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a, 0x1f, 0x52, 0x65,
|
||||
0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xf9, 0x02,
|
||||
0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70,
|
||||
0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52,
|
||||
0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d,
|
||||
0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20,
|
||||
0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
|
||||
0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65,
|
||||
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
|
||||
0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05,
|
||||
0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d,
|
||||
0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67,
|
||||
0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65,
|
||||
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70,
|
||||
0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65,
|
||||
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74,
|
||||
0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
|
||||
0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
|
||||
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
|
||||
0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74,
|
||||
0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
||||
0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f,
|
||||
0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55,
|
||||
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69,
|
||||
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12,
|
||||
0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
|
||||
0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a,
|
||||
0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a,
|
||||
0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||
0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72,
|
||||
0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||
0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39,
|
||||
0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09,
|
||||
0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f,
|
||||
0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d,
|
||||
0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x23, 0x0a,
|
||||
0x0d, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0c,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4e, 0x61,
|
||||
0x6d, 0x65, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
|
||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x1b, 0x0a, 0x19, 0x5f, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c,
|
||||
0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f,
|
||||
0x69, 0x64, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65,
|
||||
0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65,
|
||||
0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65,
|
||||
0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f,
|
||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d,
|
||||
0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22,
|
||||
0x21, 0x0a, 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x22, 0xf9, 0x02, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
|
||||
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
|
||||
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21,
|
||||
0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61,
|
||||
0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||
0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||
0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
|
||||
0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d,
|
||||
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a,
|
||||
0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e,
|
||||
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
|
||||
0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d,
|
||||
0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a,
|
||||
0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72,
|
||||
0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c,
|
||||
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02,
|
||||
0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f,
|
||||
0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69,
|
||||
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a,
|
||||
0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b,
|
||||
0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d,
|
||||
0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a,
|
||||
0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52,
|
||||
0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72,
|
||||
0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64,
|
||||
0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70,
|
||||
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12,
|
||||
0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28,
|
||||
0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||
0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72,
|
||||
0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
|
||||
0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
|
||||
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
||||
0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61,
|
||||
0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||
@@ -1343,129 +1297,185 @@ var file_enterprise_aibridged_proto_aibridged_proto_rawDesc = []byte{
|
||||
0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50,
|
||||
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01,
|
||||
0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f,
|
||||
0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a,
|
||||
0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69,
|
||||
0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
|
||||
0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72,
|
||||
0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64,
|
||||
0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72,
|
||||
0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75,
|
||||
0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e,
|
||||
0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07,
|
||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65,
|
||||
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55,
|
||||
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61,
|
||||
0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
|
||||
0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61,
|
||||
0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
|
||||
0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20,
|
||||
0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64,
|
||||
0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||
0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
||||
0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75,
|
||||
0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64,
|
||||
0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72,
|
||||
0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e,
|
||||
0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74,
|
||||
0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18,
|
||||
0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65,
|
||||
0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||
0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12,
|
||||
0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
|
||||
0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65,
|
||||
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
|
||||
0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a,
|
||||
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67,
|
||||
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41,
|
||||
0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a,
|
||||
0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75,
|
||||
0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47,
|
||||
0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
|
||||
0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
|
||||
0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
|
||||
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f,
|
||||
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f,
|
||||
0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f,
|
||||
0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
|
||||
0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
|
||||
0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
|
||||
0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68,
|
||||
0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03,
|
||||
0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65,
|
||||
0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65,
|
||||
0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69,
|
||||
0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
|
||||
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c,
|
||||
0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67,
|
||||
0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f,
|
||||
0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f,
|
||||
0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 0x47, 0x65,
|
||||
0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d,
|
||||
0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda,
|
||||
0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
|
||||
0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70,
|
||||
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75,
|
||||
0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a,
|
||||
0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65,
|
||||
0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c,
|
||||
0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f,
|
||||
0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22,
|
||||
0x72, 0x0a, 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65,
|
||||
0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63,
|
||||
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52,
|
||||
0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65,
|
||||
0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
|
||||
0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63,
|
||||
0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52,
|
||||
0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a,
|
||||
0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76,
|
||||
0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61,
|
||||
0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f,
|
||||
0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a,
|
||||
0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45,
|
||||
0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a,
|
||||
0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01,
|
||||
0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74,
|
||||
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03,
|
||||
0x28, 0x0b, 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
|
||||
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72,
|
||||
0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f,
|
||||
0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45,
|
||||
0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
|
||||
0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
|
||||
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x27, 0x0a, 0x13, 0x49,
|
||||
0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x03, 0x6b, 0x65, 0x79, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
|
||||
0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08,
|
||||
0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b,
|
||||
0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69,
|
||||
0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d,
|
||||
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d,
|
||||
0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59,
|
||||
0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
|
||||
0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45,
|
||||
0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
|
||||
0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45,
|
||||
0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72,
|
||||
0x22, 0x27, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41,
|
||||
0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a,
|
||||
0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73,
|
||||
0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73,
|
||||
0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
|
||||
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63,
|
||||
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d,
|
||||
0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f,
|
||||
0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73,
|
||||
0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65,
|
||||
0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75,
|
||||
0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68,
|
||||
0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01,
|
||||
0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f,
|
||||
0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
|
||||
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e,
|
||||
0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
|
||||
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
|
||||
0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e,
|
||||
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68,
|
||||
0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
|
||||
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a,
|
||||
0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
|
||||
0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65,
|
||||
0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f,
|
||||
0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||
0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50,
|
||||
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76,
|
||||
0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61,
|
||||
0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41,
|
||||
0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41,
|
||||
0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73,
|
||||
0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
|
||||
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f,
|
||||
0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
|
||||
0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65,
|
||||
0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
|
||||
0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65,
|
||||
0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
|
||||
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47,
|
||||
0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
|
||||
0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75,
|
||||
0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65,
|
||||
0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70,
|
||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -48,6 +48,7 @@ message RecordInterceptionRequest {
|
||||
string user_agent = 9;
|
||||
optional string correlating_tool_call_id = 10;
|
||||
optional string client_session_id = 11;
|
||||
string provider_name = 12;
|
||||
}
|
||||
|
||||
message RecordInterceptionResponse {}
|
||||
|
||||
@@ -29,6 +29,7 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri
|
||||
ApiKeyId: t.apiKeyID,
|
||||
InitiatorId: req.InitiatorID,
|
||||
Provider: req.Provider,
|
||||
ProviderName: req.ProviderName,
|
||||
Model: req.Model,
|
||||
UserAgent: req.UserAgent,
|
||||
Client: req.Client,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -172,6 +173,11 @@ func (s *Server) RecordInterception(ctx context.Context, in *proto.RecordInterce
|
||||
s.logger.Warn(ctx, "failed to marshal aibridge metadata from proto to JSON", slog.F("metadata", in), slog.Error(err))
|
||||
}
|
||||
|
||||
providerName := strings.TrimSpace(in.ProviderName)
|
||||
if providerName == "" {
|
||||
providerName = in.Provider
|
||||
}
|
||||
|
||||
_, err = s.store.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{
|
||||
ID: intcID,
|
||||
APIKeyID: sql.NullString{String: in.ApiKeyId, Valid: true},
|
||||
@@ -179,6 +185,7 @@ func (s *Server) RecordInterception(ctx context.Context, in *proto.RecordInterce
|
||||
ClientSessionID: sql.NullString{String: in.GetClientSessionId(), Valid: in.GetClientSessionId() != ""},
|
||||
InitiatorID: initID,
|
||||
Provider: in.Provider,
|
||||
ProviderName: providerName,
|
||||
Model: in.Model,
|
||||
Metadata: out,
|
||||
StartedAt: in.StartedAt.AsTime(),
|
||||
|
||||
@@ -380,13 +380,14 @@ func TestRecordInterception(t *testing.T) {
|
||||
{
|
||||
name: "valid interception",
|
||||
request: &proto.RecordInterceptionRequest{
|
||||
Id: uuid.NewString(),
|
||||
ApiKeyId: uuid.NewString(),
|
||||
InitiatorId: uuid.NewString(),
|
||||
Provider: "anthropic",
|
||||
Model: "claude-4-opus",
|
||||
Metadata: metadataProto,
|
||||
StartedAt: timestamppb.Now(),
|
||||
Id: uuid.NewString(),
|
||||
ApiKeyId: uuid.NewString(),
|
||||
InitiatorId: uuid.NewString(),
|
||||
Provider: "anthropic",
|
||||
ProviderName: "anthropic",
|
||||
Model: "claude-4-opus",
|
||||
Metadata: metadataProto,
|
||||
StartedAt: timestamppb.Now(),
|
||||
},
|
||||
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionRequest) {
|
||||
interceptionID, err := uuid.Parse(req.GetId())
|
||||
@@ -395,20 +396,22 @@ func TestRecordInterception(t *testing.T) {
|
||||
assert.NoError(t, err, "parse interception initiator UUID")
|
||||
|
||||
db.EXPECT().InsertAIBridgeInterception(gomock.Any(), database.InsertAIBridgeInterceptionParams{
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage(metadataJSON),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
ProviderName: req.GetProviderName(),
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage(metadataJSON),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}).Return(database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
ProviderName: req.GetProviderName(),
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
@@ -435,6 +438,7 @@ func TestRecordInterception(t *testing.T) {
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
ProviderName: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage(metadataJSON),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
@@ -444,6 +448,7 @@ func TestRecordInterception(t *testing.T) {
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
ProviderName: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
ClientSessionID: sql.NullString{String: "session-abc-123", Valid: true},
|
||||
@@ -473,17 +478,19 @@ func TestRecordInterception(t *testing.T) {
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
ProviderName: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage(metadataJSON),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
ClientSessionID: sql.NullString{},
|
||||
}).Return(database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: req.GetProvider(),
|
||||
ProviderName: req.GetProvider(),
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
@@ -523,6 +530,113 @@ func TestRecordInterception(t *testing.T) {
|
||||
},
|
||||
expectedErr: "empty API key ID",
|
||||
},
|
||||
{
|
||||
name: "provider name differs from provider type",
|
||||
request: &proto.RecordInterceptionRequest{
|
||||
Id: uuid.NewString(),
|
||||
ApiKeyId: uuid.NewString(),
|
||||
InitiatorId: uuid.NewString(),
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot-business",
|
||||
Model: "gpt-4o",
|
||||
StartedAt: timestamppb.Now(),
|
||||
},
|
||||
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionRequest) {
|
||||
interceptionID, err := uuid.Parse(req.GetId())
|
||||
assert.NoError(t, err, "parse interception UUID")
|
||||
initiatorID, err := uuid.Parse(req.GetInitiatorId())
|
||||
assert.NoError(t, err, "parse interception initiator UUID")
|
||||
|
||||
db.EXPECT().InsertAIBridgeInterception(gomock.Any(), database.InsertAIBridgeInterceptionParams{
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot-business",
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage("{}"),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}).Return(database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot-business",
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty provider name defaults to provider",
|
||||
request: &proto.RecordInterceptionRequest{
|
||||
Id: uuid.NewString(),
|
||||
ApiKeyId: uuid.NewString(),
|
||||
InitiatorId: uuid.NewString(),
|
||||
Provider: "copilot",
|
||||
Model: "gpt-4o",
|
||||
StartedAt: timestamppb.Now(),
|
||||
},
|
||||
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionRequest) {
|
||||
interceptionID, err := uuid.Parse(req.GetId())
|
||||
assert.NoError(t, err, "parse interception UUID")
|
||||
initiatorID, err := uuid.Parse(req.GetInitiatorId())
|
||||
assert.NoError(t, err, "parse interception initiator UUID")
|
||||
|
||||
db.EXPECT().InsertAIBridgeInterception(gomock.Any(), database.InsertAIBridgeInterceptionParams{
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot",
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage("{}"),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}).Return(database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot",
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace provider name defaults to provider",
|
||||
request: &proto.RecordInterceptionRequest{
|
||||
Id: uuid.NewString(),
|
||||
ApiKeyId: uuid.NewString(),
|
||||
InitiatorId: uuid.NewString(),
|
||||
Provider: "copilot",
|
||||
ProviderName: " ",
|
||||
Model: "gpt-4o",
|
||||
StartedAt: timestamppb.Now(),
|
||||
},
|
||||
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionRequest) {
|
||||
interceptionID, err := uuid.Parse(req.GetId())
|
||||
assert.NoError(t, err, "parse interception UUID")
|
||||
initiatorID, err := uuid.Parse(req.GetInitiatorId())
|
||||
assert.NoError(t, err, "parse interception initiator UUID")
|
||||
|
||||
db.EXPECT().InsertAIBridgeInterception(gomock.Any(), database.InsertAIBridgeInterceptionParams{
|
||||
ID: interceptionID,
|
||||
APIKeyID: sql.NullString{String: req.ApiKeyId, Valid: true},
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot",
|
||||
Model: req.GetModel(),
|
||||
Metadata: json.RawMessage("{}"),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}).Return(database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "copilot",
|
||||
ProviderName: "copilot",
|
||||
Model: req.GetModel(),
|
||||
StartedAt: req.StartedAt.AsTime().UTC(),
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "database error",
|
||||
request: &proto.RecordInterceptionRequest{
|
||||
|
||||
@@ -291,6 +291,7 @@ func TestUserOIDC(t *testing.T) {
|
||||
},
|
||||
DeploymentValues: func(dv *codersdk.DeploymentValues) {
|
||||
dv.OIDC.UserRoleField = "roles"
|
||||
dv.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ func seedWaitingChat(
|
||||
t.Helper()
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: model.ID,
|
||||
Title: title,
|
||||
|
||||
@@ -140,7 +140,7 @@ require (
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/go-playground/validator/v10 v10.30.0
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/gohugoio/hugo v0.158.0
|
||||
github.com/gohugoio/hugo v0.159.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8
|
||||
@@ -219,7 +219,7 @@ require (
|
||||
golang.org/x/tools v0.43.0
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
|
||||
google.golang.org/api v0.273.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -416,7 +416,7 @@ require (
|
||||
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
|
||||
github.com/tcnksm/go-httpstat v0.2.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.10 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.11 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tinylib/msgp v1.2.5 // indirect
|
||||
@@ -434,7 +434,7 @@ require (
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/yashtewari/glob-intersection v0.2.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/yuin/goldmark v1.7.17 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zclconf/go-cty v1.17.0
|
||||
@@ -535,9 +535,8 @@ require (
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
|
||||
github.com/charmbracelet/x/json v0.2.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/coder/paralleltestctx v0.0.1 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
|
||||
@@ -302,12 +302,10 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
|
||||
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
@@ -613,8 +611,8 @@ github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxU
|
||||
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
|
||||
github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU=
|
||||
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.158.0 h1:cGka98gfd4tPYhwURq9WHV86xi0K6DQT1wuOANRgm7Y=
|
||||
github.com/gohugoio/hugo v0.158.0/go.mod h1:ZUEvpTK4ZiTvsFk7MjBAadEB+Kt+G6wRhJJ14OjY1DA=
|
||||
github.com/gohugoio/hugo v0.159.2 h1:tpS6pcShcP3Khl8WA1NAxVHi2D3/ib9BbM8+m7NECUA=
|
||||
github.com/gohugoio/hugo v0.159.2/go.mod h1:vKww5V9i8MYzFD8JVvhRN+AKdDfKV0UvbFpmCDtTr/A=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.6.0 h1:c16engMi6zyOGeCrP73RWC9fom94wXGpVzncu3GXBjI=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.6.0/go.mod h1:e3+TRCT4Uz6NkZOAVMOMgPeJ+7KEtQMX8hdB+WG4qRs=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.4.0 h1:awFlqaCQ0N/RS9ndIBpDYNms101I1sGbDRG1bksa5Js=
|
||||
@@ -962,12 +960,12 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
|
||||
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
|
||||
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
|
||||
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
|
||||
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||
github.com/open-policy-agent/opa v1.11.0 h1:eOd/jJrbavakiX477yT4LrXZfUWViAot/AsKsjsfe7o=
|
||||
github.com/open-policy-agent/opa v1.11.0/go.mod h1:QimuJO4T3KYxWzrmAymqlFvsIanCjKrGjmmC8GgAdgE=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1 h1:lK/3zr73guK9apbXTcnDnYrC0YCQ25V3CIULYz3k2xU=
|
||||
@@ -1145,10 +1143,10 @@ github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/tdewolff/minify/v2 v2.24.10 h1:SjOOY2Y3Uv34WY4wtyUzJA2T1Xd1v1zQVSZvPP0A/h4=
|
||||
github.com/tdewolff/minify/v2 v2.24.10/go.mod h1:fXkGpJ4gel+z1nmeIjVtKmxGZ4ZXd7g1gA3dfTz5/j8=
|
||||
github.com/tdewolff/parse/v2 v2.8.10 h1:5a8o388UmuiU3zlOBJ56PN0rxVi67LRNED/zzuHAfC0=
|
||||
github.com/tdewolff/parse/v2 v2.8.10/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/minify/v2 v2.24.11 h1:JlANsiWaRBXedoYtsiZgY3YFkdr42oF32vp2SLgQKi4=
|
||||
github.com/tdewolff/minify/v2 v2.24.11/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
|
||||
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
|
||||
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||
@@ -1254,8 +1252,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -1377,8 +1375,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -1529,8 +1527,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
|
||||
@@ -126,9 +126,12 @@ echo_latest_mainline_version() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Filter to strict semver (MAJOR.MINOR.PATCH) to exclude
|
||||
# pre-release tags like RC builds from version resolution.
|
||||
echo "$body" |
|
||||
awk -F'"' '/"tag_name"/ {print $4}' |
|
||||
tr -d v |
|
||||
grep '^[0-9]\+\.[0-9]\+\.[0-9]\+$' |
|
||||
tr . ' ' |
|
||||
sort -k1,1nr -k2,2nr -k3,3nr |
|
||||
head -n1 |
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./scripts/backport-pr.sh [--dry-run] <release-version> <pr-number>
|
||||
#
|
||||
# Backports a merged PR to a release branch by cherry-picking its merge commit
|
||||
# and opening a new PR targeting the release branch.
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/backport-pr.sh 2.30 23969
|
||||
# ./scripts/backport-pr.sh --dry-run 2.30 23969
|
||||
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cdroot
|
||||
|
||||
dry_run=0
|
||||
|
||||
# Parse flags.
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run | -n)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
error "Unknown flag: $1"
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 [--dry-run] <release-version> <pr-number>" >&2
|
||||
echo " e.g. $0 2.30 23969" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="$1"
|
||||
pr_number="$2"
|
||||
release_branch="release/${release_version}"
|
||||
|
||||
dependencies gh jq git
|
||||
|
||||
# Authenticate with GitHub.
|
||||
gh_auth
|
||||
|
||||
# Validate that the PR exists and is merged.
|
||||
log "Fetching PR #${pr_number}..."
|
||||
pr_json=$(gh pr view "$pr_number" --json mergeCommit,title,number,state,headRefName,url)
|
||||
|
||||
pr_state=$(echo "$pr_json" | jq -r '.state')
|
||||
if [[ "$pr_state" != "MERGED" ]]; then
|
||||
error "PR #${pr_number} is not merged (state: ${pr_state})."
|
||||
fi
|
||||
|
||||
merge_commit=$(echo "$pr_json" | jq -r '.mergeCommit.oid')
|
||||
pr_title=$(echo "$pr_json" | jq -r '.title')
|
||||
pr_url=$(echo "$pr_json" | jq -r '.url')
|
||||
|
||||
if [[ -z "$merge_commit" || "$merge_commit" == "null" ]]; then
|
||||
error "Could not determine merge commit for PR #${pr_number}."
|
||||
fi
|
||||
|
||||
log "PR: #${pr_number} - ${pr_title}"
|
||||
log "Merge commit: ${merge_commit}"
|
||||
log "Release branch: ${release_branch}"
|
||||
|
||||
# Make sure we have the latest refs.
|
||||
maybedryrun "$dry_run" git fetch origin
|
||||
|
||||
# Validate the release branch exists on the remote.
|
||||
if ! git rev-parse "origin/${release_branch}" >/dev/null 2>&1; then
|
||||
error "Release branch '${release_branch}' does not exist on origin."
|
||||
fi
|
||||
|
||||
backport_branch="backport/${pr_number}-to-${release_version}"
|
||||
log "Backport branch: ${backport_branch}"
|
||||
|
||||
if [[ "$dry_run" == 1 ]]; then
|
||||
log ""
|
||||
log "DRYRUN: Would cherry-pick ${merge_commit} onto ${release_branch} via branch ${backport_branch}"
|
||||
log "DRYRUN: Would create PR targeting ${release_branch}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes that would block checkout.
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
error "You have uncommitted changes. Please commit or stash them first."
|
||||
fi
|
||||
|
||||
# Create the backport branch from the release branch.
|
||||
log "Creating branch ${backport_branch} from origin/${release_branch}..."
|
||||
git checkout -b "$backport_branch" "origin/${release_branch}"
|
||||
|
||||
# Cherry-pick the merge commit.
|
||||
log "Cherry-picking ${merge_commit}..."
|
||||
if ! git cherry-pick "$merge_commit"; then
|
||||
log ""
|
||||
log "Cherry-pick failed due to conflicts."
|
||||
log "Resolve the conflicts, then run:"
|
||||
log " git cherry-pick --continue"
|
||||
log " git push origin ${backport_branch}"
|
||||
log " gh pr create --base ${release_branch} --head ${backport_branch} --title \"chore: backport #${pr_number} to ${release_version}\" --body \"Backport of ${pr_url}\""
|
||||
log ""
|
||||
log "Or abort with: git cherry-pick --abort && git checkout - && git branch -D ${backport_branch}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Push the backport branch.
|
||||
log "Pushing ${backport_branch}..."
|
||||
git push origin "$backport_branch"
|
||||
|
||||
# Create the PR.
|
||||
log "Creating PR..."
|
||||
backport_pr_url=$(gh pr create \
|
||||
--draft \
|
||||
--label "cherry-pick/v${release_version}" \
|
||||
--base "$release_branch" \
|
||||
--head "$backport_branch" \
|
||||
--title "chore: backport #${pr_number} to ${release_version}" \
|
||||
--body "$(
|
||||
cat <<EOF
|
||||
Backport of ${pr_url}
|
||||
|
||||
Original PR: #${pr_number} — ${pr_title}
|
||||
Merge commit: ${merge_commit}
|
||||
EOF
|
||||
)")
|
||||
|
||||
log ""
|
||||
log "Backport PR created: ${backport_pr_url}"
|
||||
|
||||
# Return to previous branch.
|
||||
git checkout -
|
||||
@@ -34,11 +34,12 @@ if [[ "${CI:-}" == "" ]]; then
|
||||
fi
|
||||
|
||||
stable=0
|
||||
rc=0
|
||||
version=""
|
||||
release_notes_file=""
|
||||
dry_run=0
|
||||
|
||||
args="$(getopt -o "" -l stable,version:,release-notes-file:,dry-run -- "$@")"
|
||||
args="$(getopt -o "" -l stable,rc,version:,release-notes-file:,dry-run -- "$@")"
|
||||
eval set -- "$args"
|
||||
while true; do
|
||||
case "$1" in
|
||||
@@ -46,6 +47,10 @@ while true; do
|
||||
stable=1
|
||||
shift
|
||||
;;
|
||||
--rc)
|
||||
rc=1
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
version="$2"
|
||||
shift 2
|
||||
@@ -68,6 +73,10 @@ while true; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$stable" == 1 ]] && [[ "$rc" == 1 ]]; then
|
||||
error "Cannot specify both --stable and --rc"
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
dependencies gh
|
||||
|
||||
@@ -162,6 +171,11 @@ if [[ "$stable" == 1 ]]; then
|
||||
latest=true
|
||||
fi
|
||||
|
||||
prerelease_flag=()
|
||||
if [[ "$rc" == 1 ]]; then
|
||||
prerelease_flag=(--prerelease)
|
||||
fi
|
||||
|
||||
target_commitish=main # This is the default.
|
||||
# Skip during dry-runs
|
||||
if [[ "$dry_run" == 0 ]]; then
|
||||
@@ -176,6 +190,7 @@ fi
|
||||
true |
|
||||
maybedryrun "$dry_run" gh release create \
|
||||
--latest="$latest" \
|
||||
"${prerelease_flag[@]}" \
|
||||
--title "$new_tag" \
|
||||
--target "$target_commitish" \
|
||||
--notes-file "$release_notes_file" \
|
||||
|
||||
+78
-34
@@ -30,9 +30,11 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
}
|
||||
|
||||
var latestMainline *version
|
||||
if len(allTags) > 0 {
|
||||
v := allTags[0]
|
||||
latestMainline = &v
|
||||
for _, t := range allTags {
|
||||
if t.Pre == "" {
|
||||
latestMainline = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
stableMinor := -1
|
||||
@@ -41,7 +43,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
stableMinor = latestMainline.Minor - 1
|
||||
// Find highest tag in the stable minor series.
|
||||
for _, t := range allTags {
|
||||
if t.Major == latestMainline.Major && t.Minor == stableMinor {
|
||||
if t.Major == latestMainline.Major && t.Minor == stableMinor && t.Pre == "" {
|
||||
latestStableStr = t.String()
|
||||
break
|
||||
}
|
||||
@@ -66,15 +68,17 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
return xerrors.Errorf("detecting branch: %w", err)
|
||||
}
|
||||
|
||||
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
|
||||
// Match standard release branches (release/2.32) and RC
|
||||
// branches (release/2.32-rc.0).
|
||||
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
|
||||
m := branchRe.FindStringSubmatch(currentBranch)
|
||||
if m == nil {
|
||||
warnf(w, "Current branch %q is not a release branch (release/X.Y).", currentBranch)
|
||||
warnf(w, "Current branch %q is not a release branch (release/X.Y or release/X.Y-rc.N).", currentBranch)
|
||||
branchInput, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Enter the release branch to use (e.g. release/2.21)",
|
||||
Text: "Enter the release branch to use (e.g. release/2.21 or release/2.21-rc.0)",
|
||||
Validate: func(s string) error {
|
||||
if !branchRe.MatchString(s) {
|
||||
return xerrors.New("must be in format release/X.Y (e.g. release/2.21)")
|
||||
return xerrors.New("must be in format release/X.Y or release/X.Y-rc.N (e.g. release/2.21 or release/2.21-rc.0)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -87,6 +91,10 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
}
|
||||
branchMajor, _ := strconv.Atoi(m[1])
|
||||
branchMinor, _ := strconv.Atoi(m[2])
|
||||
branchRC := -1 // -1 means not an RC branch.
|
||||
if m[3] != "" {
|
||||
branchRC, _ = strconv.Atoi(m[3])
|
||||
}
|
||||
successf(w, "Using release branch: %s", currentBranch)
|
||||
|
||||
// --- Fetch & sync check ---
|
||||
@@ -134,9 +142,27 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
if prevVersion == nil {
|
||||
infof(w, "No previous release tag found on this branch.")
|
||||
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
|
||||
if branchRC >= 0 {
|
||||
suggested.Pre = fmt.Sprintf("rc.%d", branchRC)
|
||||
}
|
||||
} else {
|
||||
infof(w, "Previous release tag: %s", prevVersion.String())
|
||||
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
|
||||
if branchRC >= 0 {
|
||||
// On an RC branch, suggest the next RC for
|
||||
// the same base version.
|
||||
nextRC := 0
|
||||
if prevVersion.IsRC() {
|
||||
nextRC = prevVersion.rcNumber() + 1
|
||||
}
|
||||
suggested = version{
|
||||
Major: prevVersion.Major,
|
||||
Minor: prevVersion.Minor,
|
||||
Patch: prevVersion.Patch,
|
||||
Pre: fmt.Sprintf("rc.%d", nextRC),
|
||||
}
|
||||
} else {
|
||||
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
@@ -147,7 +173,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
Default: suggested.String(),
|
||||
Validate: func(s string) error {
|
||||
if _, ok := parseVersion(s); !ok {
|
||||
return xerrors.New("must be in format vMAJOR.MINOR.PATCH (e.g. v2.31.1)")
|
||||
return xerrors.New("must be in format vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N (e.g. v2.31.1 or v2.31.0-rc.0)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -303,29 +329,36 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
// --- Channel selection ---
|
||||
// This is done before release notes generation because the
|
||||
// notes format differs between mainline and stable channels.
|
||||
channelDefault := cliui.ConfirmNo
|
||||
channelHint := ""
|
||||
if newVersion.Minor == stableMinor {
|
||||
channelDefault = cliui.ConfirmYes
|
||||
channelHint = " (this looks like a stable release)"
|
||||
}
|
||||
|
||||
// RC releases are always on the "rc" channel and skip the
|
||||
// stable/mainline prompt.
|
||||
channel := "mainline"
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Mark this as the latest stable release on GitHub?%s", channelHint),
|
||||
Default: channelDefault,
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err == nil {
|
||||
channel = "stable"
|
||||
} else if !errors.Is(err, cliui.ErrCanceled) {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel == "stable" {
|
||||
infof(w, "Channel: stable (will be marked as GitHub Latest).")
|
||||
if newVersion.IsRC() {
|
||||
channel = "rc"
|
||||
infof(w, "Channel: rc (release candidate, will be marked as prerelease on GitHub).")
|
||||
} else {
|
||||
infof(w, "Channel: mainline (will be marked as prerelease).")
|
||||
channelDefault := cliui.ConfirmNo
|
||||
channelHint := ""
|
||||
if newVersion.Minor == stableMinor {
|
||||
channelDefault = cliui.ConfirmYes
|
||||
channelHint = " (this looks like a stable release)"
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Mark this as the latest stable release on GitHub?%s", channelHint),
|
||||
Default: channelDefault,
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err == nil {
|
||||
channel = "stable"
|
||||
} else if !errors.Is(err, cliui.ErrCanceled) {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel == "stable" {
|
||||
infof(w, "Channel: stable (will be marked as GitHub Latest).")
|
||||
} else {
|
||||
infof(w, "Channel: mainline (will be marked as prerelease).")
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
@@ -408,12 +441,17 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
// scripts/release/generate_release_notes.sh.
|
||||
var notes strings.Builder
|
||||
|
||||
// Stable since header or mainline blurb.
|
||||
// Stable since header, mainline blurb, or RC advisory.
|
||||
if channel == "stable" {
|
||||
fmt.Fprintf(¬es, "> ## Stable (since %s)\n\n", time.Now().Format("January 02, 2006"))
|
||||
}
|
||||
fmt.Fprintln(¬es, "## Changelog")
|
||||
if channel == "mainline" {
|
||||
switch channel {
|
||||
case "rc":
|
||||
fmt.Fprintln(¬es)
|
||||
fmt.Fprintln(¬es, "> [!NOTE]")
|
||||
fmt.Fprintln(¬es, "> This is a **release candidate** (RC) for testing purposes. It is not recommended for production use. Please report any issues you encounter. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).")
|
||||
case "mainline":
|
||||
fmt.Fprintln(¬es)
|
||||
fmt.Fprintln(¬es, "> [!NOTE]")
|
||||
fmt.Fprintln(¬es, "> This is a mainline Coder release. We advise enterprise customers without a staging environment to install our [latest stable release](https://github.com/coder/coder/releases/latest) while we refine this version. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).")
|
||||
@@ -576,7 +614,13 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
successf(w, "Release workflow triggered!")
|
||||
|
||||
// --- Update release docs ---
|
||||
promptAndUpdateDocs(inv, newVersion, channel, dryRun)
|
||||
// RC releases skip docs updates (calendar, helm versions, etc.)
|
||||
// since they are not production releases.
|
||||
if newVersion.IsRC() {
|
||||
infof(w, "Skipping docs update for release candidate.")
|
||||
} else {
|
||||
promptAndUpdateDocs(inv, newVersion, channel, dryRun)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
successf(w, "Done! 🎉")
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// version holds a parsed semver version.
|
||||
// version holds a parsed semver version with optional prerelease
|
||||
// suffix (e.g. "rc.0").
|
||||
type version struct {
|
||||
Major int
|
||||
Minor int
|
||||
Patch int
|
||||
Pre string // e.g. "rc.0", "" for stable releases.
|
||||
}
|
||||
|
||||
var semverRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`)
|
||||
var semverRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)(-(.+))?$`)
|
||||
|
||||
func parseVersion(s string) (version, bool) {
|
||||
m := semverRe.FindStringSubmatch(s)
|
||||
@@ -24,13 +26,35 @@ func parseVersion(s string) (version, bool) {
|
||||
maj, _ := strconv.Atoi(m[1])
|
||||
mnr, _ := strconv.Atoi(m[2])
|
||||
pat, _ := strconv.Atoi(m[3])
|
||||
return version{Major: maj, Minor: mnr, Patch: pat}, true
|
||||
return version{Major: maj, Minor: mnr, Patch: pat, Pre: m[5]}, true
|
||||
}
|
||||
|
||||
func (v version) String() string {
|
||||
if v.Pre != "" {
|
||||
return fmt.Sprintf("v%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre)
|
||||
}
|
||||
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
|
||||
}
|
||||
|
||||
// IsRC returns true when the version has a prerelease suffix starting
|
||||
// with "rc." (e.g. "rc.0", "rc.1").
|
||||
func (v version) IsRC() bool {
|
||||
return strings.HasPrefix(v.Pre, "rc.")
|
||||
}
|
||||
|
||||
// rcNumber returns the numeric RC identifier (e.g. 0 for "rc.0").
|
||||
// It returns -1 when the version is not an RC.
|
||||
func (v version) rcNumber() int {
|
||||
if !v.IsRC() {
|
||||
return -1
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(v.Pre, "rc."))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (v version) GreaterThan(b version) bool {
|
||||
if v.Major != b.Major {
|
||||
return v.Major > b.Major
|
||||
@@ -38,11 +62,27 @@ func (v version) GreaterThan(b version) bool {
|
||||
if v.Minor != b.Minor {
|
||||
return v.Minor > b.Minor
|
||||
}
|
||||
return v.Patch > b.Patch
|
||||
if v.Patch != b.Patch {
|
||||
return v.Patch > b.Patch
|
||||
}
|
||||
// A release without prerelease suffix is greater than one
|
||||
// with a prerelease suffix (v2.32.0 > v2.32.0-rc.0).
|
||||
if v.Pre == "" && b.Pre != "" {
|
||||
return true
|
||||
}
|
||||
if v.Pre != "" && b.Pre == "" {
|
||||
return false
|
||||
}
|
||||
// Both have prerelease: compare numerically for RC versions.
|
||||
if v.IsRC() && b.IsRC() {
|
||||
return v.rcNumber() > b.rcNumber()
|
||||
}
|
||||
// Fallback for non-RC prerelease strings.
|
||||
return v.Pre > b.Pre
|
||||
}
|
||||
|
||||
func (v version) Equal(b version) bool {
|
||||
return v.Major == b.Major && v.Minor == b.Minor && v.Patch == b.Patch
|
||||
return v.Major == b.Major && v.Minor == b.Minor && v.Patch == b.Patch && v.Pre == b.Pre
|
||||
}
|
||||
|
||||
// allSemverTags returns all semver tags sorted descending.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
ok bool
|
||||
want version
|
||||
}{
|
||||
{"v2.32.0", true, version{2, 32, 0, ""}},
|
||||
{"v1.0.0", true, version{1, 0, 0, ""}},
|
||||
{"v2.32.0-rc.0", true, version{2, 32, 0, "rc.0"}},
|
||||
{"v2.32.0-rc.1", true, version{2, 32, 0, "rc.1"}},
|
||||
{"v2.32.1-beta.3", true, version{2, 32, 1, "beta.3"}},
|
||||
{"2.32.0", false, version{}},
|
||||
{"v2.32", false, version{}},
|
||||
{"vx.y.z", false, version{}},
|
||||
{"", false, version{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := parseVersion(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Fatalf("parseVersion(%q) ok = %v, want %v", tt.input, ok, tt.ok)
|
||||
}
|
||||
if ok && got != tt.want {
|
||||
t.Fatalf("parseVersion(%q) = %+v, want %+v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
v version
|
||||
want string
|
||||
}{
|
||||
{version{2, 32, 0, ""}, "v2.32.0"},
|
||||
{version{2, 32, 0, "rc.0"}, "v2.32.0-rc.0"},
|
||||
{version{1, 0, 0, "beta.1"}, "v1.0.0-beta.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.v.String(); got != tt.want {
|
||||
t.Fatalf("String() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionIsRC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
v version
|
||||
want bool
|
||||
}{
|
||||
{version{2, 32, 0, "rc.0"}, true},
|
||||
{version{2, 32, 0, "rc.1"}, true},
|
||||
{version{2, 32, 0, ""}, false},
|
||||
{version{2, 32, 0, "beta.1"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.v.String(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.v.IsRC(); got != tt.want {
|
||||
t.Fatalf("IsRC() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionRCNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
v version
|
||||
want int
|
||||
}{
|
||||
{version{2, 32, 0, "rc.0"}, 0},
|
||||
{version{2, 32, 0, "rc.5"}, 5},
|
||||
{version{2, 32, 0, ""}, -1},
|
||||
{version{2, 32, 0, "beta.1"}, -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.v.String(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.v.rcNumber(); got != tt.want {
|
||||
t.Fatalf("rcNumber() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionGreaterThan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
a, b version
|
||||
want bool
|
||||
}{
|
||||
// Standard comparisons.
|
||||
{version{2, 32, 1, ""}, version{2, 32, 0, ""}, true},
|
||||
{version{2, 32, 0, ""}, version{2, 32, 1, ""}, false},
|
||||
{version{2, 33, 0, ""}, version{2, 32, 0, ""}, true},
|
||||
{version{3, 0, 0, ""}, version{2, 99, 99, ""}, true},
|
||||
|
||||
// Release > RC with same base version.
|
||||
{version{2, 32, 0, ""}, version{2, 32, 0, "rc.0"}, true},
|
||||
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, ""}, false},
|
||||
|
||||
// RC ordering.
|
||||
{version{2, 32, 0, "rc.1"}, version{2, 32, 0, "rc.0"}, true},
|
||||
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.1"}, false},
|
||||
{version{2, 32, 0, "rc.10"}, version{2, 32, 0, "rc.9"}, true},
|
||||
{version{2, 32, 0, "rc.9"}, version{2, 32, 0, "rc.10"}, false},
|
||||
|
||||
// Equal.
|
||||
{version{2, 32, 0, ""}, version{2, 32, 0, ""}, false},
|
||||
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.0"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a.String()+"_gt_"+tt.b.String(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.a.GreaterThan(tt.b); got != tt.want {
|
||||
t.Fatalf("%s.GreaterThan(%s) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionEqual(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
a, b version
|
||||
want bool
|
||||
}{
|
||||
{version{2, 32, 0, ""}, version{2, 32, 0, ""}, true},
|
||||
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.0"}, true},
|
||||
{version{2, 32, 0, ""}, version{2, 32, 0, "rc.0"}, false},
|
||||
{version{2, 32, 0, "rc.0"}, version{2, 32, 0, "rc.1"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a.String()+"_eq_"+tt.b.String(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.a.Equal(tt.b); got != tt.want {
|
||||
t.Fatalf("%s.Equal(%s) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export default {
|
||||
options: {},
|
||||
},
|
||||
|
||||
core: {
|
||||
allowedHosts: [".coder", ".dev.coder.com"],
|
||||
},
|
||||
|
||||
async viteFinal(config) {
|
||||
// Storybook seems to strip this setting out of our Vite config. We need to
|
||||
// put it back in order to be able to access Storybook with Coder Desktop or
|
||||
|
||||
+20
-3
@@ -11,6 +11,7 @@ import { API } from "#/api/api";
|
||||
import type {
|
||||
UpdateTemplateMeta,
|
||||
WorkspaceBuildParameter,
|
||||
WorkspaceStatus,
|
||||
} from "#/api/typesGenerated";
|
||||
import { TarWriter } from "#/utils/tar";
|
||||
import {
|
||||
@@ -423,7 +424,8 @@ export const startWorkspaceWithEphemeralParameters = async (
|
||||
await page.getByTestId("workspace-parameters").click();
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByRole("button", { name: "Update and restart" }).click();
|
||||
|
||||
await page.getByRole("button", { name: /update and start/i }).click();
|
||||
|
||||
await page.waitForSelector("text=Workspace status: Running", {
|
||||
state: "visible",
|
||||
@@ -1177,6 +1179,7 @@ export const updateTemplateSettings = async (
|
||||
export const updateWorkspace = async (
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
workspaceStatus: WorkspaceStatus,
|
||||
richParameters: RichParameter[] = [],
|
||||
buildParameters: WorkspaceBuildParameter[] = [],
|
||||
) => {
|
||||
@@ -1194,12 +1197,19 @@ export const updateWorkspace = async (
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
|
||||
await page.getByRole("button", { name: /update and restart/i }).click();
|
||||
if (workspaceStatus === "running") {
|
||||
await page.getByRole("button", { name: /update and restart/i }).click();
|
||||
// Confirmation dialog.
|
||||
await page.getByRole("button", { name: /restart/i }).click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: /update and start/i }).click();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWorkspaceParameters = async (
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
workspaceStatus: WorkspaceStatus,
|
||||
richParameters: RichParameter[] = [],
|
||||
buildParameters: WorkspaceBuildParameter[] = [],
|
||||
) => {
|
||||
@@ -1209,7 +1219,14 @@ export const updateWorkspaceParameters = async (
|
||||
});
|
||||
|
||||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByRole("button", { name: /update and restart/i }).click();
|
||||
|
||||
if (workspaceStatus === "running") {
|
||||
await page.getByRole("button", { name: /update and restart/i }).click();
|
||||
// Confirmation dialog.
|
||||
await page.getByRole("button", { name: /restart/i }).click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: /update and start/i }).click();
|
||||
}
|
||||
|
||||
await page.waitForSelector("text=Workspace status: Running", {
|
||||
state: "visible",
|
||||
|
||||
@@ -48,7 +48,7 @@ test("add and remove a group", async ({ page }) => {
|
||||
|
||||
// Select the group from the list and add it
|
||||
await page.getByText(groupName).click();
|
||||
await page.getByText("Add member").click();
|
||||
await page.getByText("Add").click();
|
||||
const row = page.locator(".MuiTableRow-root", { hasText: groupName });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ test.skip("update workspace, new optional, immutable parameter added", async ({
|
||||
|
||||
// Now, update the workspace, and select the value for immutable parameter.
|
||||
await login(page, users.member);
|
||||
await updateWorkspace(page, workspaceName, updatedRichParameters, [
|
||||
await updateWorkspace(page, workspaceName, "running", updatedRichParameters, [
|
||||
{ name: fifthParameter.name, value: fifthParameter.options[0].value },
|
||||
]);
|
||||
|
||||
@@ -108,6 +108,7 @@ test("update workspace, new required, mutable parameter added", async ({
|
||||
await updateWorkspace(
|
||||
page,
|
||||
workspaceName,
|
||||
"stopped",
|
||||
updatedRichParameters,
|
||||
buildParameters,
|
||||
);
|
||||
@@ -146,6 +147,7 @@ test("update workspace with ephemeral parameter enabled", async ({ page }) => {
|
||||
await updateWorkspaceParameters(
|
||||
page,
|
||||
workspaceName,
|
||||
"running",
|
||||
richParameters,
|
||||
buildParameters,
|
||||
);
|
||||
|
||||
+1
-16
@@ -63,22 +63,6 @@
|
||||
"@mui/x-tree-view": "7.29.10",
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"@pierre/diffs": "1.1.0-beta.19",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@tanstack/react-query-devtools": "5.77.0",
|
||||
"@xterm/addon-canvas": "0.7.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
@@ -109,6 +93,7 @@
|
||||
"monaco-editor": "0.55.1",
|
||||
"motion": "12.34.1",
|
||||
"pretty-bytes": "6.1.1",
|
||||
"radix-ui": "1.4.3",
|
||||
"react": "19.2.2",
|
||||
"react-color": "2.19.3",
|
||||
"react-confetti": "6.4.0",
|
||||
|
||||
Generated
+586
-174
File diff suppressed because it is too large
Load Diff
@@ -741,7 +741,7 @@ describe("mutation invalidation scope", () => {
|
||||
seedAllActiveQueries(queryClient, chatId);
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
mutation.onSuccess();
|
||||
mutation.onSettled();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@@ -760,7 +760,7 @@ describe("mutation invalidation scope", () => {
|
||||
seedAllActiveQueries(queryClient, chatId);
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
mutation.onSuccess();
|
||||
mutation.onSettled();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@@ -778,6 +778,180 @@ describe("mutation invalidation scope", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Shared type for the infinite messages cache shape used by
|
||||
// editChatMessage tests below.
|
||||
type InfMessages = {
|
||||
pages: TypesGen.ChatMessagesResponse[];
|
||||
pageParams: (number | undefined)[];
|
||||
};
|
||||
|
||||
const makeMsg = (chatId: string, id: number): TypesGen.ChatMessage => ({
|
||||
id,
|
||||
chat_id: chatId,
|
||||
created_at: `2025-01-01T00:00:${String(id).padStart(2, "0")}Z`,
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: `msg ${id}` }],
|
||||
});
|
||||
|
||||
const editReq = {
|
||||
content: [{ type: "text" as const, text: "edited" }],
|
||||
};
|
||||
|
||||
it("editChatMessage optimistically removes truncated messages from cache", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const messages = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id));
|
||||
|
||||
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
|
||||
pages: [{ messages, queued_messages: [], has_more: false }],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
const context = await mutation.onMutate({
|
||||
messageId: 3,
|
||||
req: editReq,
|
||||
});
|
||||
|
||||
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
|
||||
expect(data?.pages[0]?.messages.map((m) => m.id)).toEqual([2, 1]);
|
||||
expect(context?.previousData?.pages[0]?.messages).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("editChatMessage restores cache on error", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const messages = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id));
|
||||
|
||||
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
|
||||
pages: [{ messages, queued_messages: [], has_more: false }],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
const context = await mutation.onMutate({
|
||||
messageId: 3,
|
||||
req: editReq,
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId))?.pages[0]
|
||||
?.messages,
|
||||
).toHaveLength(2);
|
||||
|
||||
mutation.onError(
|
||||
new Error("network failure"),
|
||||
{ messageId: 3, req: editReq },
|
||||
context,
|
||||
);
|
||||
|
||||
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
|
||||
expect(data?.pages[0]?.messages.map((m) => m.id)).toEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it("editChatMessage onMutate is a no-op when cache is empty", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
const context = await mutation.onMutate({
|
||||
messageId: 3,
|
||||
req: editReq,
|
||||
});
|
||||
|
||||
expect(context.previousData).toBeUndefined();
|
||||
expect(queryClient.getQueryData(chatMessagesKey(chatId))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("editChatMessage onError handles undefined context gracefully", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const messages = [3, 2, 1].map((id) => makeMsg(chatId, id));
|
||||
|
||||
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
|
||||
pages: [{ messages, queued_messages: [], has_more: false }],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
|
||||
// Pass undefined context — simulates onMutate throwing before
|
||||
// it could return a snapshot.
|
||||
mutation.onError(
|
||||
new Error("fail"),
|
||||
{ messageId: 2, req: editReq },
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Cache should be untouched — no crash, no corruption.
|
||||
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
|
||||
expect(data?.pages[0]?.messages.map((m) => m.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it("editChatMessage onMutate filters across multiple pages", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
|
||||
// Page 0 (newest): IDs 10–6. Page 1 (older): IDs 5–1.
|
||||
const page0 = [10, 9, 8, 7, 6].map((id) => makeMsg(chatId, id));
|
||||
const page1 = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id));
|
||||
|
||||
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
|
||||
pages: [
|
||||
{ messages: page0, queued_messages: [], has_more: true },
|
||||
{ messages: page1, queued_messages: [], has_more: false },
|
||||
],
|
||||
pageParams: [undefined, 6],
|
||||
});
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
await mutation.onMutate({ messageId: 7, req: editReq });
|
||||
|
||||
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
|
||||
// Page 0: only ID 6 survives (< 7).
|
||||
expect(data?.pages[0]?.messages.map((m) => m.id)).toEqual([6]);
|
||||
// Page 1: all survive (all < 7).
|
||||
expect(data?.pages[1]?.messages.map((m) => m.id)).toEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it("editChatMessage onMutate editing the first message empties all pages", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const messages = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id));
|
||||
|
||||
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
|
||||
pages: [{ messages, queued_messages: [], has_more: false }],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
await mutation.onMutate({ messageId: 1, req: editReq });
|
||||
|
||||
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
|
||||
// All messages have id >= 1, so the page is empty.
|
||||
expect(data?.pages[0]?.messages).toHaveLength(0);
|
||||
// Sibling fields survive the spread.
|
||||
expect(data?.pages[0]?.queued_messages).toEqual([]);
|
||||
expect(data?.pages[0]?.has_more).toBe(false);
|
||||
});
|
||||
|
||||
it("editChatMessage onMutate editing the latest message keeps earlier ones", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
const messages = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id));
|
||||
|
||||
queryClient.setQueryData<InfMessages>(chatMessagesKey(chatId), {
|
||||
pages: [{ messages, queued_messages: [], has_more: false }],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
|
||||
const mutation = editChatMessage(queryClient, chatId);
|
||||
await mutation.onMutate({ messageId: 5, req: editReq });
|
||||
|
||||
const data = queryClient.getQueryData<InfMessages>(chatMessagesKey(chatId));
|
||||
expect(data?.pages[0]?.messages.map((m) => m.id)).toEqual([4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it("interruptChat does not invalidate unrelated queries", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const chatId = "chat-1";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { QueryClient, UseInfiniteQueryOptions } from "react-query";
|
||||
import type {
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
UseInfiniteQueryOptions,
|
||||
} from "react-query";
|
||||
import { API } from "#/api/api";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
|
||||
@@ -602,11 +606,65 @@ type EditChatMessageMutationArgs = {
|
||||
export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
|
||||
mutationFn: ({ messageId, req }: EditChatMessageMutationArgs) =>
|
||||
API.experimental.editChatMessage(chatId, messageId, req),
|
||||
onSuccess: () => {
|
||||
// Editing truncates all messages after the edited one on the
|
||||
// server. The WebSocket can insert/update messages but cannot
|
||||
// remove stale ones, so a full messages refetch is required.
|
||||
// Use exact matching to avoid cascading to unrelated queries
|
||||
onMutate: async ({ messageId }: EditChatMessageMutationArgs) => {
|
||||
// Cancel in-flight refetches so they don't overwrite the
|
||||
// optimistic update before the mutation completes.
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: chatMessagesKey(chatId),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const previousData = queryClient.getQueryData<
|
||||
InfiniteData<TypesGen.ChatMessagesResponse>
|
||||
>(chatMessagesKey(chatId));
|
||||
|
||||
// Optimistically remove the edited message and everything
|
||||
// after it. The server soft-deletes these and inserts a
|
||||
// replacement with a new ID. Without this, the WebSocket
|
||||
// handler's upsertCacheMessages adds new messages to the
|
||||
// React Query cache without removing the soft-deleted ones,
|
||||
// causing deleted messages to flash back into view until
|
||||
// the full REST refetch resolves.
|
||||
queryClient.setQueryData<
|
||||
InfiniteData<TypesGen.ChatMessagesResponse> | undefined
|
||||
>(chatMessagesKey(chatId), (current) => {
|
||||
if (!current?.pages?.length) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page) => ({
|
||||
...page,
|
||||
messages: page.messages.filter((m) => m.id < messageId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onError: (
|
||||
_error: unknown,
|
||||
_variables: EditChatMessageMutationArgs,
|
||||
context:
|
||||
| {
|
||||
previousData?:
|
||||
| InfiniteData<TypesGen.ChatMessagesResponse>
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
// Restore the cache on failure so the user sees the
|
||||
// original messages again.
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(chatMessagesKey(chatId), context.previousData);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always reconcile with the server regardless of whether
|
||||
// the mutation succeeded or failed. On success this picks
|
||||
// up the replacement message; on failure it confirms the
|
||||
// restore from onError matches the server state. Use exact
|
||||
// matching to avoid cascading to unrelated queries
|
||||
// (diff-status, diff-contents, cost summaries, etc.).
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: chatKey(chatId),
|
||||
|
||||
Generated
+1
@@ -70,6 +70,7 @@ export interface AIBridgeInterception {
|
||||
readonly api_key_id: string | null;
|
||||
readonly initiator: MinimalUser;
|
||||
readonly provider: string;
|
||||
readonly provider_name: string;
|
||||
readonly model: string;
|
||||
readonly client: string | null;
|
||||
// empty interface{} type, falling back to unknown
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
* It was also simplified to make usage easier and reduce boilerplate.
|
||||
* @see {@link https://github.com/coder/coder/pull/15930#issuecomment-2552292440}
|
||||
*/
|
||||
|
||||
import { useTheme } from "@emotion/react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui";
|
||||
import { getExternalImageStylesFromUrl } from "#/theme/externalImages";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 11/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/badge}
|
||||
*/
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const badgeVariants = cva(
|
||||
@@ -78,7 +78,7 @@ export const Badge: React.FC<BadgeProps> = ({
|
||||
asChild = false,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 12/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/breadcrumb}
|
||||
*/
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
type BreadcrumbProps = React.ComponentPropsWithRef<"nav"> & {
|
||||
@@ -53,7 +53,7 @@ export const BreadcrumbLink: React.FC<BreadcrumbLinkProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
const Comp = asChild ? Slot.Root : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 11/06/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/button}
|
||||
*/
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
// Be careful when changing the child styles from the button such as images
|
||||
@@ -69,7 +69,7 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
asChild = false,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
// We want `type` to default to `"button"` when the component is not being
|
||||
// used as a `Slot`. The default behavior of any given `<button>` element is
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 04/03/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/checkbox}
|
||||
*/
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* Copied from shadc/ui on 12/26/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/collapsible}
|
||||
*/
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 11/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/dialog}
|
||||
*/
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
* This component was updated to match the styles from the Figma design:
|
||||
* @see {@link https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=656-2354&t=CiGt5le3yJEwMH4M-0}
|
||||
*/
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 11/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/label}
|
||||
*/
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const labelVariants = cva(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Slot, Slottable } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { SquareArrowOutUpRightIcon } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const linkVariants = cva(
|
||||
@@ -37,10 +37,10 @@ export const Link: React.FC<LinkProps> = ({
|
||||
showExternalIcon = true,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
const Comp = asChild ? Slot.Root : "a";
|
||||
return (
|
||||
<Comp className={cn(linkVariants({ size }), className)} {...props}>
|
||||
<Slottable>{children}</Slottable>
|
||||
<Slot.Slottable>{children}</Slot.Slottable>
|
||||
{showExternalIcon && <SquareArrowOutUpRightIcon />}
|
||||
</Comp>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copied from shadcn/ui and modified on 12/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/popover}
|
||||
*/
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export type PopoverContentProps = React.ComponentPropsWithRef<
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 04/04/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/radio-group}
|
||||
*/
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const RadioGroup: React.FC<
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copied from shadc/ui on 03/05/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
|
||||
*/
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Copied from shadc/ui on 13/01/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/select}
|
||||
*/
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import {
|
||||
Check,
|
||||
ChevronUp,
|
||||
ChevronDown as LucideChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
/**
|
||||
* Copied from shadc/ui on 06/20/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/separator}
|
||||
*/
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copied from shadc/ui on 04/16/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/slider}
|
||||
*/
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const Slider: React.FC<
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Copied from shadc/ui on 11/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/switch}
|
||||
*/
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Switch as SwitchPrimitives } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const switchVariants = cva(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copied from shadc/ui on 02/05/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/tooltip}
|
||||
*/
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
@@ -59,6 +59,26 @@ export const HasError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const HasErrorMenuOpen: Story = {
|
||||
args: {
|
||||
devcontainer: {
|
||||
...MockWorkspaceAgentDevcontainer,
|
||||
error: "unable to inject devcontainer with agent",
|
||||
container: undefined,
|
||||
agent: undefined,
|
||||
},
|
||||
subAgents: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await user.click(
|
||||
canvas.getByRole("button", { name: "Dev Container actions" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPorts: Story = {};
|
||||
|
||||
export const WithPorts: Story = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user