Compare commits

..

3 Commits

Author SHA1 Message Date
Danielle Maywood e39c106fa9 fix whitespace in useChatStore.ts 2026-04-07 09:06:28 +00:00
Danielle Maywood 9b281d2347 drop redundant comment; replace useEffect with render-time adjustment for ancestor expansion 2026-04-07 09:00:20 +00:00
Danielle Maywood 4ca34441e7 refactor(site/pages/AgentsPage): replace useRef+useEffect sync patterns with useEffectEvent
Replace five instances of the useRef + no-deps useEffect pattern
(used to avoid dependency churn) with the existing useEffectEvent
polyfill. Each change wraps the appropriate handler/callback that
consumed the ref, rather than creating getter-style wrappers.

Changes:
- useChatStore.ts: wrap connect handler in connectChatStream
  useEffectEvent; use effect-local variable for WS message IDs
- AgentsPage.tsx: extract WS message handler into
  handleChatListEvent useEffectEvent; wrap navigateAfterArchive
- AgentsSidebar.tsx: wrap ancestor-expansion handler in
  expandAncestors useEffectEvent
- useFileAttachments.ts: wrap cleanup handler in revokePreviewUrls
  useEffectEvent
- AgentCreateForm.tsx: wrap handleSend in useEffectEvent
2026-04-06 23:52:55 +00:00
136 changed files with 1786 additions and 4980 deletions
+3 -4
View File
@@ -31,8 +31,7 @@ updates:
patterns:
- "golang.org/x/*"
ignore:
# Patch updates are handled by the security-patch-prs workflow so this
# lane stays focused on broader dependency updates.
# Ignore patch updates for all dependencies
- dependency-name: "*"
update-types:
- version-update:semver-patch
@@ -57,7 +56,7 @@ updates:
labels: []
ignore:
# We need to coordinate terraform updates with the version hardcoded in
# our Go code. These are handled by the security-patch-prs workflow.
# our Go code.
- dependency-name: "terraform"
- package-ecosystem: "npm"
@@ -118,11 +117,11 @@ updates:
interval: "weekly"
commit-message:
prefix: "chore"
labels: []
groups:
coder-modules:
patterns:
- "coder/*/coder"
labels: []
ignore:
- dependency-name: "*"
update-types:
+17 -17
View File
@@ -35,7 +35,7 @@ jobs:
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -157,7 +157,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -247,7 +247,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -272,7 +272,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -327,7 +327,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -379,7 +379,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -575,7 +575,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -637,7 +637,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -709,7 +709,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -736,7 +736,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -769,7 +769,7 @@ jobs:
name: ${{ matrix.variant.name }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -849,7 +849,7 @@ jobs:
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -930,7 +930,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1005,7 +1005,7 @@ jobs:
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1043,7 +1043,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1097,7 +1097,7 @@ jobs:
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -1479,7 +1479,7 @@ jobs:
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v3.0.0
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -65,7 +65,7 @@ jobs:
packages: write # to retag image as dogfood
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -142,7 +142,7 @@ jobs:
needs: deploy
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -125,7 +125,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+5 -5
View File
@@ -39,7 +39,7 @@ jobs:
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -76,7 +76,7 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -184,7 +184,7 @@ jobs:
pull-requests: write # needed for commenting on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -228,7 +228,7 @@ jobs:
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -288,7 +288,7 @@ jobs:
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+3 -3
View File
@@ -81,7 +81,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -673,7 +673,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -749,7 +749,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -47,6 +47,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
with:
sarif_file: results.sarif
-354
View File
@@ -1,354 +0,0 @@
name: security-backport
on:
pull_request_target:
types:
- labeled
- unlabeled
- closed
workflow_dispatch:
inputs:
pull_request:
description: Pull request number to backport.
required: true
type: string
permissions:
contents: write
pull-requests: write
issues: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pull_request }}
cancel-in-progress: false
env:
LATEST_BRANCH: release/2.31
STABLE_BRANCH: release/2.30
STABLE_1_BRANCH: release/2.29
jobs:
label-policy:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Apply security backport label policy
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
pr_number="$(jq -r '.pull_request.number' "${GITHUB_EVENT_PATH}")"
pr_json="$(gh pr view "${pr_number}" --json number,title,url,baseRefName,labels)"
PR_JSON="${pr_json}" \
python3 - <<'PY'
import json
import os
import subprocess
import sys
pr = json.loads(os.environ["PR_JSON"])
pr_number = pr["number"]
labels = [label["name"] for label in pr.get("labels", [])]
def has(label: str) -> bool:
return label in labels
def ensure_label(label: str) -> None:
if not has(label):
subprocess.run(
["gh", "pr", "edit", str(pr_number), "--add-label", label],
check=False,
)
def remove_label(label: str) -> None:
if has(label):
subprocess.run(
["gh", "pr", "edit", str(pr_number), "--remove-label", label],
check=False,
)
def comment(body: str) -> None:
subprocess.run(
["gh", "pr", "comment", str(pr_number), "--body", body],
check=True,
)
if not has("security:patch"):
remove_label("status:needs-severity")
sys.exit(0)
severity_labels = [
label
for label in ("severity:medium", "severity:high", "severity:critical")
if has(label)
]
if len(severity_labels) == 0:
ensure_label("status:needs-severity")
comment(
"This PR is labeled `security:patch` but is missing a severity "
"label. Add one of `severity:medium`, `severity:high`, or "
"`severity:critical` before backport automation can proceed."
)
sys.exit(0)
if len(severity_labels) > 1:
comment(
"This PR has multiple severity labels. Keep exactly one of "
"`severity:medium`, `severity:high`, or `severity:critical`."
)
sys.exit(1)
remove_label("status:needs-severity")
target_labels = [
label
for label in ("backport:stable", "backport:stable-1")
if has(label)
]
has_none = has("backport:none")
if has_none and target_labels:
comment(
"`backport:none` cannot be combined with other backport labels. "
"Remove `backport:none` or remove the explicit backport targets."
)
sys.exit(1)
if not has_none and not target_labels:
ensure_label("backport:stable")
ensure_label("backport:stable-1")
comment(
"Applied default backport labels `backport:stable` and "
"`backport:stable-1` for a qualifying security patch."
)
PY
backport:
if: >
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true
)
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve PR metadata
id: metadata
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_PR_NUMBER: ${{ inputs.pull_request }}
LATEST_BRANCH: ${{ env.LATEST_BRANCH }}
STABLE_BRANCH: ${{ env.STABLE_BRANCH }}
STABLE_1_BRANCH: ${{ env.STABLE_1_BRANCH }}
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
pr_number="${INPUT_PR_NUMBER}"
else
pr_number="$(jq -r '.pull_request.number' "${GITHUB_EVENT_PATH}")"
fi
case "${pr_number}" in
''|*[!0-9]*)
echo "A valid pull request number is required."
exit 1
;;
esac
pr_json="$(gh pr view "${pr_number}" --json number,title,url,mergeCommit,baseRefName,labels,mergedAt,author)"
PR_JSON="${pr_json}" \
python3 - <<'PY'
import json
import os
import sys
pr = json.loads(os.environ["PR_JSON"])
github_output = os.environ["GITHUB_OUTPUT"]
labels = [label["name"] for label in pr.get("labels", [])]
if "security:patch" not in labels:
print("Not a security patch PR; skipping.")
sys.exit(0)
severity_labels = [
label
for label in ("severity:medium", "severity:high", "severity:critical")
if label in labels
]
if len(severity_labels) != 1:
raise SystemExit(
"Merged security patch PR must have exactly one severity label."
)
if not pr.get("mergedAt"):
raise SystemExit(f"PR #{pr['number']} is not merged.")
if "backport:none" in labels:
target_pairs = []
else:
mapping = {
"backport:stable": os.environ["STABLE_BRANCH"],
"backport:stable-1": os.environ["STABLE_1_BRANCH"],
}
target_pairs = []
for label_name, branch in mapping.items():
if label_name in labels and branch and branch != pr["baseRefName"]:
target_pairs.append({"label": label_name, "branch": branch})
with open(github_output, "a", encoding="utf-8") as f:
f.write(f"pr_number={pr['number']}\n")
f.write(f"merge_sha={pr['mergeCommit']['oid']}\n")
f.write(f"title={pr['title']}\n")
f.write(f"url={pr['url']}\n")
f.write(f"author={pr['author']['login']}\n")
f.write(f"severity_label={severity_labels[0]}\n")
f.write(f"target_pairs={json.dumps(target_pairs)}\n")
PY
- name: Backport to release branches
if: ${{ steps.metadata.outputs.target_pairs != '[]' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
MERGE_SHA: ${{ steps.metadata.outputs.merge_sha }}
PR_TITLE: ${{ steps.metadata.outputs.title }}
PR_URL: ${{ steps.metadata.outputs.url }}
PR_AUTHOR: ${{ steps.metadata.outputs.author }}
SEVERITY_LABEL: ${{ steps.metadata.outputs.severity_label }}
TARGET_PAIRS: ${{ steps.metadata.outputs.target_pairs }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git fetch origin --prune
merge_parent_count="$(git rev-list --parents -n 1 "${MERGE_SHA}" | awk '{print NF-1}')"
failures=()
successes=()
while IFS=$'\t' read -r backport_label target_branch; do
[ -n "${target_branch}" ] || continue
safe_branch_name="${target_branch//\//-}"
head_branch="backport/${safe_branch_name}/pr-${PR_NUMBER}"
existing_pr="$(gh pr list \
--base "${target_branch}" \
--head "${head_branch}" \
--state all \
--json number,url \
--jq '.[0]')"
if [ -n "${existing_pr}" ] && [ "${existing_pr}" != "null" ]; then
pr_url="$(printf '%s' "${existing_pr}" | jq -r '.url')"
successes+=("${target_branch}:existing:${pr_url}")
continue
fi
git checkout -B "${head_branch}" "origin/${target_branch}"
if [ "${merge_parent_count}" -gt 1 ]; then
cherry_pick_args=(-m 1 "${MERGE_SHA}")
else
cherry_pick_args=("${MERGE_SHA}")
fi
if ! git cherry-pick -x "${cherry_pick_args[@]}"; then
git cherry-pick --abort || true
gh pr edit "${PR_NUMBER}" --add-label "backport:conflict" || true
gh pr comment "${PR_NUMBER}" --body \
"Automatic backport to \`${target_branch}\` conflicted. The original author or release manager should resolve it manually."
failures+=("${target_branch}:cherry-pick failed")
continue
fi
git push --force-with-lease origin "${head_branch}"
body_file="$(mktemp)"
printf '%s\n' \
"Automated backport of [#${PR_NUMBER}](${PR_URL})." \
"" \
"- Source PR: #${PR_NUMBER}" \
"- Source commit: ${MERGE_SHA}" \
"- Target branch: ${target_branch}" \
"- Severity: ${SEVERITY_LABEL}" \
> "${body_file}"
pr_url="$(gh pr create \
--base "${target_branch}" \
--head "${head_branch}" \
--title "${PR_TITLE} (backport to ${target_branch})" \
--body-file "${body_file}")"
backport_pr_number="$(gh pr list \
--base "${target_branch}" \
--head "${head_branch}" \
--state open \
--json number \
--jq '.[0].number')"
gh pr edit "${backport_pr_number}" \
--add-label "security:patch" \
--add-label "${SEVERITY_LABEL}" \
--add-label "${backport_label}" || true
successes+=("${target_branch}:created:${pr_url}")
done < <(
python3 - <<'PY'
import json
import os
for pair in json.loads(os.environ["TARGET_PAIRS"]):
print(f"{pair['label']}\t{pair['branch']}")
PY
)
summary_file="$(mktemp)"
{
echo "## Security backport summary"
echo
if [ "${#successes[@]}" -gt 0 ]; then
echo "### Created or existing"
for entry in "${successes[@]}"; do
echo "- ${entry}"
done
echo
fi
if [ "${#failures[@]}" -gt 0 ]; then
echo "### Failures"
for entry in "${failures[@]}"; do
echo "- ${entry}"
done
fi
} | tee -a "${GITHUB_STEP_SUMMARY}" > "${summary_file}"
gh pr comment "${PR_NUMBER}" --body-file "${summary_file}"
if [ "${#failures[@]}" -gt 0 ]; then
printf 'Backport failures:\n%s\n' "${failures[@]}" >&2
exit 1
fi
-214
View File
@@ -1,214 +0,0 @@
name: security-patch-prs
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * 1-5"
permissions:
contents: write
pull-requests: write
jobs:
patch:
strategy:
fail-fast: false
matrix:
lane:
- gomod
- terraform
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Patch Go dependencies
if: matrix.lane == 'gomod'
shell: bash
run: |
set -euo pipefail
go get -u=patch ./...
go mod tidy
# Guardrail: do not auto-edit replace directives.
if git diff --unified=0 -- go.mod | grep -E '^[+-]replace '; then
echo "Refusing to auto-edit go.mod replace directives"
exit 1
fi
# Guardrail: only go.mod / go.sum may change.
extra="$(git diff --name-only | grep -Ev '^(go\.mod|go\.sum)$' || true)"
test -z "$extra" || { echo "Unexpected files changed:"; echo "$extra"; exit 1; }
- name: Patch bundled Terraform
if: matrix.lane == 'terraform'
shell: bash
run: |
set -euo pipefail
current="$(
grep -oE 'NewVersion\("[0-9]+\.[0-9]+\.[0-9]+"\)' \
provisioner/terraform/install.go \
| head -1 \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+'
)"
series="$(echo "$current" | cut -d. -f1,2)"
latest="$(
curl -fsSL https://releases.hashicorp.com/terraform/index.json \
| jq -r --arg series "$series" '
.versions
| keys[]
| select(startswith($series + "."))
' \
| sort -V \
| tail -1
)"
test -n "$latest"
[ "$latest" != "$current" ] || exit 0
CURRENT_TERRAFORM_VERSION="$current" \
LATEST_TERRAFORM_VERSION="$latest" \
python3 - <<'PY'
from pathlib import Path
import os
current = os.environ["CURRENT_TERRAFORM_VERSION"]
latest = os.environ["LATEST_TERRAFORM_VERSION"]
updates = {
"scripts/Dockerfile.base": (
f"terraform/{current}/",
f"terraform/{latest}/",
),
"provisioner/terraform/install.go": (
f'NewVersion("{current}")',
f'NewVersion("{latest}")',
),
"install.sh": (
f'TERRAFORM_VERSION="{current}"',
f'TERRAFORM_VERSION="{latest}"',
),
}
for path_str, (before, after) in updates.items():
path = Path(path_str)
content = path.read_text()
if before not in content:
raise SystemExit(f"did not find expected text in {path_str}: {before}")
path.write_text(content.replace(before, after))
PY
# Guardrail: only the Terraform-version files may change.
extra="$(git diff --name-only | grep -Ev '^(scripts/Dockerfile.base|provisioner/terraform/install.go|install.sh)$' || true)"
test -z "$extra" || { echo "Unexpected files changed:"; echo "$extra"; exit 1; }
- name: Validate Go dependency patch
if: matrix.lane == 'gomod'
shell: bash
run: |
set -euo pipefail
go test ./...
- name: Validate Terraform patch
if: matrix.lane == 'terraform'
shell: bash
run: |
set -euo pipefail
go test ./provisioner/terraform/...
docker build -f scripts/Dockerfile.base .
- name: Skip PR creation when there are no changes
id: changes
shell: bash
run: |
set -euo pipefail
if git diff --quiet; then
echo "has_changes=false" >> "${GITHUB_OUTPUT}"
else
echo "has_changes=true" >> "${GITHUB_OUTPUT}"
fi
- name: Commit changes
if: steps.changes.outputs.has_changes == 'true'
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "secpatch/${{ matrix.lane }}"
git add -A
git commit -m "security: patch ${{ matrix.lane }}"
- name: Push branch
if: steps.changes.outputs.has_changes == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git push --force-with-lease \
"https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \
"HEAD:refs/heads/secpatch/${{ matrix.lane }}"
- name: Create or update PR
if: steps.changes.outputs.has_changes == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
branch="secpatch/${{ matrix.lane }}"
title="security: patch ${{ matrix.lane }}"
body="$(cat <<'EOF'
Automated security patch PR for `${{ matrix.lane }}`.
Scope:
- gomod: patch-level Go dependency updates only
- terraform: bundled Terraform patch updates only
Guardrails:
- no application-code edits
- no auto-editing of go.mod replace directives
- CI must pass
EOF
)"
existing_pr="$(gh pr list --head "${branch}" --base main --json number --jq '.[0].number // empty')"
if [[ -n "${existing_pr}" ]]; then
gh pr edit "${existing_pr}" \
--title "${title}" \
--body "${body}"
pr_number="${existing_pr}"
else
gh pr create \
--base main \
--head "${branch}" \
--title "${title}" \
--body "${body}"
pr_number="$(gh pr list --head "${branch}" --base main --json number --jq '.[0].number // empty')"
fi
for label in security dependencies automated-pr; do
if gh label list --json name --jq '.[].name' | grep -Fxq "${label}"; then
gh pr edit "${pr_number}" --add-label "${label}"
fi
done
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -40,7 +40,7 @@ jobs:
uses: ./.github/actions/setup-go
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
with:
languages: go, javascript
@@ -50,7 +50,7 @@ jobs:
rm Makefile
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
- name: Send Slack notification on failure
if: ${{ failure() }}
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -96,7 +96,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
@@ -120,7 +120,7 @@ jobs:
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
pull-requests: write # required to post PR review comments by the action
steps:
- name: Harden Runner
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
+19 -28
View File
@@ -3,13 +3,11 @@
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
"defaultBranch": "main",
},
"files": {
// static/*.html are Go templates with {{ }} directives that
// Biome's HTML parser does not support.
"includes": ["**", "!**/pnpm-lock.yaml", "!**/static/*.html"],
"ignoreUnknown": true
"includes": ["**", "!**/pnpm-lock.yaml"],
"ignoreUnknown": true,
},
"linter": {
"rules": {
@@ -17,7 +15,7 @@
"noSvgWithoutTitle": "off",
"useButtonType": "off",
"useSemanticElements": "off",
"noStaticElementInteractions": "off"
"noStaticElementInteractions": "off",
},
"correctness": {
"noUnusedImports": "warn",
@@ -26,9 +24,9 @@
"noUnusedVariables": {
"level": "warn",
"options": {
"ignoreRestSiblings": true
}
}
"ignoreRestSiblings": true,
},
},
},
"style": {
"noNonNullAssertion": "off",
@@ -49,7 +47,7 @@
"paths": {
"react": {
"message": "React 19 no longer requires forwardRef. Use ref as a prop instead.",
"importNames": ["forwardRef"]
"importNames": ["forwardRef"],
},
// "@mui/material/Alert": "Use components/Alert/Alert instead.",
// "@mui/material/AlertTitle": "Use components/Alert/Alert instead.",
@@ -117,10 +115,10 @@
"@emotion/styled": "Use Tailwind CSS instead.",
// "@emotion/cache": "Use Tailwind CSS instead.",
// "components/Stack/Stack": "Use Tailwind flex utilities instead (e.g., <div className='flex flex-col gap-4'>).",
"lodash": "Use lodash/<name> instead."
}
}
}
"lodash": "Use lodash/<name> instead.",
},
},
},
},
"suspicious": {
"noArrayIndexKey": "off",
@@ -131,21 +129,14 @@
"noConsole": {
"level": "error",
"options": {
"allow": ["error", "info", "warn"]
}
}
"allow": ["error", "info", "warn"],
},
},
},
"complexity": {
"noImportantStyles": "off" // TODO: check and fix !important styles
}
}
"noImportantStyles": "off", // TODO: check and fix !important styles
},
},
},
"css": {
"parser": {
// Biome 2.3+ requires opt-in for @apply and other
// Tailwind directives.
"tailwindDirectives": true
}
},
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
}
-6
View File
@@ -14175,9 +14175,6 @@ const docTemplate = `{
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
@@ -14499,9 +14496,6 @@ const docTemplate = `{
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
-6
View File
@@ -12739,9 +12739,6 @@
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
@@ -13042,9 +13039,6 @@
},
"count": {
"type": "integer"
},
"count_cap": {
"type": "integer"
}
}
},
+1 -8
View File
@@ -26,11 +26,6 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// Limit the count query to avoid a slow sequential scan due to joins
// on a large table. Set to 0 to disable capping (but also see the note
// in the SQL query).
const auditLogCountCap = 2000
// @Summary Get audit logs
// @ID get-audit-logs
// @Security CoderSessionToken
@@ -71,7 +66,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
countFilter.Username = ""
}
countFilter.CountCap = auditLogCountCap
// Use the same filters to count the number of audit logs
count, err := api.Database.CountAuditLogs(ctx, countFilter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
@@ -86,7 +81,6 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: []codersdk.AuditLog{},
Count: 0,
CountCap: auditLogCountCap,
})
return
}
@@ -104,7 +98,6 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
AuditLogs: api.convertAuditLogs(ctx, dblogs),
Count: count,
CountCap: auditLogCountCap,
})
}
+3 -19
View File
@@ -1528,10 +1528,7 @@ func nullInt64Ptr(v sql.NullInt64) *int64 {
// Chat converts a database.Chat to a codersdk.Chat. It coalesces
// nil slices and maps to empty values for JSON serialization and
// derives RootChatID from the parent chain when not explicitly set.
// When diffStatus is non-nil the response includes diff metadata.
// When files is non-empty the response includes file metadata;
// pass nil to omit the files field (e.g. list endpoints).
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database.GetChatFileMetadataByChatIDRow) codersdk.Chat {
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
mcpServerIDs := c.MCPServerIDs
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
@@ -1584,19 +1581,6 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
convertedDiffStatus := ChatDiffStatus(c.ID, diffStatus)
chat.DiffStatus = &convertedDiffStatus
}
if len(files) > 0 {
chat.Files = make([]codersdk.ChatFileMetadata, 0, len(files))
for _, row := range files {
chat.Files = append(chat.Files, codersdk.ChatFileMetadata{
ID: row.ID,
OwnerID: row.OwnerID,
OrganizationID: row.OrganizationID,
Name: row.Name,
MimeType: row.Mimetype,
CreatedAt: row.CreatedAt,
})
}
}
if c.LastInjectedContext.Valid {
var parts []codersdk.ChatMessagePart
// Internal fields are stripped at write time in
@@ -1620,9 +1604,9 @@ func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]da
for i, row := range rows {
diffStatus, ok := diffStatusesByChatID[row.Chat.ID]
if ok {
result[i] = Chat(row.Chat, &diffStatus, nil)
result[i] = Chat(row.Chat, &diffStatus)
} else {
result[i] = Chat(row.Chat, nil, nil)
result[i] = Chat(row.Chat, nil)
if diffStatusesByChatID != nil {
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
+4 -122
View File
@@ -561,26 +561,14 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
ChatID: input.ID,
}
fileRows := []database.GetChatFileMetadataByChatIDRow{
{
ID: uuid.New(),
OwnerID: input.OwnerID,
OrganizationID: uuid.New(),
Name: "test.png",
Mimetype: "image/png",
CreatedAt: now,
},
}
got := db2sdk.Chat(input, diffStatus, fileRows)
got := db2sdk.Chat(input, diffStatus)
v := reflect.ValueOf(got)
typ := v.Type()
// HasUnread is populated by ChatRows (which joins the
// read-cursor query), not by Chat. Warnings is a transient
// field populated by handlers, not the converter. Both are
// expected to remain zero here.
skip := map[string]bool{"HasUnread": true, "Warnings": true}
// read-cursor query), not by Chat, so it is expected
// to remain zero here.
skip := map[string]bool{"HasUnread": true}
for i := range typ.NumField() {
field := typ.Field(i)
if skip[field.Name] {
@@ -593,112 +581,6 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
}
}
func TestChat_FileMetadataConversion(t *testing.T) {
t.Parallel()
ownerID := uuid.New()
orgID := uuid.New()
fileID := uuid.New()
now := dbtime.Now()
chat := database.Chat{
ID: uuid.New(),
OwnerID: ownerID,
LastModelConfigID: uuid.New(),
Title: "file metadata test",
Status: database.ChatStatusWaiting,
CreatedAt: now,
UpdatedAt: now,
}
rows := []database.GetChatFileMetadataByChatIDRow{
{
ID: fileID,
OwnerID: ownerID,
OrganizationID: orgID,
Name: "screenshot.png",
Mimetype: "image/png",
CreatedAt: now,
},
}
result := db2sdk.Chat(chat, nil, rows)
require.Len(t, result.Files, 1)
f := result.Files[0]
require.Equal(t, fileID, f.ID)
require.Equal(t, ownerID, f.OwnerID, "OwnerID must be mapped from DB row")
require.Equal(t, orgID, f.OrganizationID, "OrganizationID must be mapped from DB row")
require.Equal(t, "screenshot.png", f.Name)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, now, f.CreatedAt)
// Verify JSON serialization uses snake_case for mime_type.
data, err := json.Marshal(f)
require.NoError(t, err)
require.Contains(t, string(data), `"mime_type"`)
require.NotContains(t, string(data), `"mimetype"`)
}
func TestChat_NilFilesOmitted(t *testing.T) {
t.Parallel()
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
LastModelConfigID: uuid.New(),
Title: "no files",
Status: database.ChatStatusWaiting,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
}
result := db2sdk.Chat(chat, nil, nil)
require.Empty(t, result.Files)
}
func TestChat_MultipleFiles(t *testing.T) {
t.Parallel()
now := dbtime.Now()
file1 := uuid.New()
file2 := uuid.New()
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
LastModelConfigID: uuid.New(),
Title: "multi file test",
Status: database.ChatStatusWaiting,
CreatedAt: now,
UpdatedAt: now,
}
rows := []database.GetChatFileMetadataByChatIDRow{
{
ID: file1,
OwnerID: chat.OwnerID,
OrganizationID: uuid.New(),
Name: "a.png",
Mimetype: "image/png",
CreatedAt: now,
},
{
ID: file2,
OwnerID: chat.OwnerID,
OrganizationID: uuid.New(),
Name: "b.txt",
Mimetype: "text/plain",
CreatedAt: now,
},
}
result := db2sdk.Chat(chat, nil, rows)
require.Len(t, result.Files, 2)
require.Equal(t, "a.png", result.Files[0].Name)
require.Equal(t, "b.txt", result.Files[1].Name)
}
func TestChatQueuedMessage_MalformedContent(t *testing.T) {
t.Parallel()
+8 -23
View File
@@ -2583,10 +2583,6 @@ func (q *querier) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.C
return file, nil
}
func (q *querier) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatFileMetadataByChatID)(ctx, chatID)
}
func (q *querier) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
files, err := q.db.GetChatFilesByIDs(ctx, ids)
if err != nil {
@@ -5397,17 +5393,6 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab
return q.db.InsertWorkspaceResourceMetadata(ctx, arg)
}
func (q *querier) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.LinkChatFiles(ctx, arg)
}
func (q *querier) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
@@ -5782,15 +5767,15 @@ func (q *querier) UpdateChatByID(ctx context.Context, arg database.UpdateChatByI
return q.db.UpdateChatByID(ctx, arg)
}
func (q *querier) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
// The batch heartbeat is a system-level operation filtered by
// worker_id. Authorization is enforced by the AsChatd context
// at the call site rather than per-row, because checking each
// row individually would defeat the purpose of batching.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return nil, err
func (q *querier) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return 0, err
}
return q.db.UpdateChatHeartbeats(ctx, arg)
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.UpdateChatHeartbeat(ctx, arg)
}
func (q *querier) UpdateChatLabelsByID(ctx context.Context, arg database.UpdateChatLabelsByIDParams) (database.Chat, error) {
+7 -31
View File
@@ -400,17 +400,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return([]database.Chat{chat}, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns([]database.Chat{chat})
}))
s.Run("LinkChatFiles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{uuid.New()},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().LinkChatFiles(gomock.Any(), arg).Return(int32(0), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int32(0))
}))
s.Run("PinChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
@@ -587,19 +576,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatFilesByIDs(gomock.Any(), []uuid.UUID{file.ID}).Return([]database.ChatFile{file}, nil).AnyTimes()
check.Args([]uuid.UUID{file.ID}).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns([]database.ChatFile{file})
}))
s.Run("GetChatFileMetadataByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
file := testutil.Fake(s.T(), faker, database.ChatFile{})
rows := []database.GetChatFileMetadataByChatIDRow{{
ID: file.ID,
Name: file.Name,
Mimetype: file.Mimetype,
CreatedAt: file.CreatedAt,
OwnerID: file.OwnerID,
OrganizationID: file.OrganizationID,
}}
dbm.EXPECT().GetChatFileMetadataByChatID(gomock.Any(), file.ID).Return(rows, nil).AnyTimes()
check.Args(file.ID).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns(rows)
}))
s.Run("GetChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})
@@ -842,15 +818,15 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatStatusPreserveUpdatedAt(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatHeartbeats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
resultID := uuid.New()
arg := database.UpdateChatHeartbeatsParams{
IDs: []uuid.UUID{resultID},
s.Run("UpdateChatHeartbeat", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatHeartbeatParams{
ID: chat.ID,
WorkerID: uuid.New(),
Now: time.Now(),
}
dbm.EXPECT().UpdateChatHeartbeats(gomock.Any(), arg).Return([]uuid.UUID{resultID}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns([]uuid.UUID{resultID})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatHeartbeat(gomock.Any(), arg).Return(int64(1), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("UpdateChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
+4 -20
View File
@@ -1128,14 +1128,6 @@ func (m queryMetricsStore) GetChatFileByID(ctx context.Context, id uuid.UUID) (d
return r0, r1
}
func (m queryMetricsStore) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatFileMetadataByChatID(ctx, chatID)
m.queryLatencies.WithLabelValues("GetChatFileMetadataByChatID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatFileMetadataByChatID").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
start := time.Now()
r0, r1 := m.s.GetChatFilesByIDs(ctx, ids)
@@ -3784,14 +3776,6 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
start := time.Now()
r0, r1 := m.s.LinkChatFiles(ctx, arg)
m.queryLatencies.WithLabelValues("LinkChatFiles").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "LinkChatFiles").Inc()
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeClients(ctx, arg)
@@ -4136,11 +4120,11 @@ func (m queryMetricsStore) UpdateChatByID(ctx context.Context, arg database.Upda
return r0, r1
}
func (m queryMetricsStore) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
func (m queryMetricsStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatHeartbeats(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatHeartbeats").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatHeartbeats").Inc()
r0, r1 := m.s.UpdateChatHeartbeat(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatHeartbeat").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatHeartbeat").Inc()
return r0, r1
}
+7 -37
View File
@@ -2072,21 +2072,6 @@ func (mr *MockStoreMockRecorder) GetChatFileByID(ctx, id any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFileByID", reflect.TypeOf((*MockStore)(nil).GetChatFileByID), ctx, id)
}
// GetChatFileMetadataByChatID mocks base method.
func (m *MockStore) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]database.GetChatFileMetadataByChatIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatFileMetadataByChatID", ctx, chatID)
ret0, _ := ret[0].([]database.GetChatFileMetadataByChatIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatFileMetadataByChatID indicates an expected call of GetChatFileMetadataByChatID.
func (mr *MockStoreMockRecorder) GetChatFileMetadataByChatID(ctx, chatID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatFileMetadataByChatID", reflect.TypeOf((*MockStore)(nil).GetChatFileMetadataByChatID), ctx, chatID)
}
// GetChatFilesByIDs mocks base method.
func (m *MockStore) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]database.ChatFile, error) {
m.ctrl.T.Helper()
@@ -7081,21 +7066,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), ctx, arg)
}
// LinkChatFiles mocks base method.
func (m *MockStore) LinkChatFiles(ctx context.Context, arg database.LinkChatFilesParams) (int32, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LinkChatFiles", ctx, arg)
ret0, _ := ret[0].(int32)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// LinkChatFiles indicates an expected call of LinkChatFiles.
func (mr *MockStoreMockRecorder) LinkChatFiles(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkChatFiles", reflect.TypeOf((*MockStore)(nil).LinkChatFiles), ctx, arg)
}
// ListAIBridgeClients mocks base method.
func (m *MockStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) {
m.ctrl.T.Helper()
@@ -7835,19 +7805,19 @@ func (mr *MockStoreMockRecorder) UpdateChatByID(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatByID", reflect.TypeOf((*MockStore)(nil).UpdateChatByID), ctx, arg)
}
// UpdateChatHeartbeats mocks base method.
func (m *MockStore) UpdateChatHeartbeats(ctx context.Context, arg database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
// UpdateChatHeartbeat mocks base method.
func (m *MockStore) UpdateChatHeartbeat(ctx context.Context, arg database.UpdateChatHeartbeatParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatHeartbeats", ctx, arg)
ret0, _ := ret[0].([]uuid.UUID)
ret := m.ctrl.Call(m, "UpdateChatHeartbeat", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatHeartbeats indicates an expected call of UpdateChatHeartbeats.
func (mr *MockStoreMockRecorder) UpdateChatHeartbeats(ctx, arg any) *gomock.Call {
// UpdateChatHeartbeat indicates an expected call of UpdateChatHeartbeat.
func (mr *MockStoreMockRecorder) UpdateChatHeartbeat(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatHeartbeats", reflect.TypeOf((*MockStore)(nil).UpdateChatHeartbeats), ctx, arg)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateChatHeartbeat), ctx, arg)
}
// UpdateChatLabelsByID mocks base method.
-16
View File
@@ -1269,11 +1269,6 @@ CREATE TABLE chat_diff_statuses (
head_branch text
);
CREATE TABLE chat_file_links (
chat_id uuid NOT NULL,
file_id uuid NOT NULL
);
CREATE TABLE chat_files (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
@@ -3349,9 +3344,6 @@ ALTER TABLE ONLY boundary_usage_stats
ALTER TABLE ONLY chat_diff_statuses
ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id);
ALTER TABLE ONLY chat_file_links
ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id);
ALTER TABLE ONLY chat_files
ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id);
@@ -3742,8 +3734,6 @@ CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC);
CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at);
CREATE INDEX idx_chat_file_links_chat_id ON chat_file_links USING btree (chat_id);
CREATE INDEX idx_chat_files_org ON chat_files USING btree (organization_id);
CREATE INDEX idx_chat_files_owner ON chat_files USING btree (owner_id);
@@ -4046,12 +4036,6 @@ ALTER TABLE ONLY api_keys
ALTER TABLE ONLY chat_diff_statuses
ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_file_links
ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_file_links
ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE;
ALTER TABLE ONLY chat_files
ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
@@ -10,8 +10,6 @@ const (
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatFileLinksChatID ForeignKeyConstraint = "chat_file_links_chat_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ForeignKeyChatFileLinksFileID ForeignKeyConstraint = "chat_file_links_file_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE;
ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
@@ -1,9 +0,0 @@
ALTER TABLE chats ADD COLUMN file_ids uuid[] DEFAULT '{}'::uuid[] NOT NULL;
UPDATE chats SET file_ids = (
SELECT COALESCE(array_agg(cfl.file_id), '{}')
FROM chat_file_links cfl
WHERE cfl.chat_id = chats.id
);
DROP TABLE chat_file_links;
@@ -1,17 +0,0 @@
CREATE TABLE chat_file_links (
chat_id uuid NOT NULL,
file_id uuid NOT NULL,
UNIQUE (chat_id, file_id)
);
CREATE INDEX idx_chat_file_links_chat_id ON chat_file_links (chat_id);
ALTER TABLE chat_file_links
ADD CONSTRAINT chat_file_links_chat_id_fkey
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE chat_file_links
ADD CONSTRAINT chat_file_links_file_id_fkey
FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE;
ALTER TABLE chats DROP COLUMN IF EXISTS file_ids;
@@ -1,5 +0,0 @@
INSERT INTO chat_file_links (chat_id, file_id)
VALUES (
'72c0438a-18eb-4688-ab80-e4c6a126ef96',
'00000000-0000-0000-0000-000000000099'
);
-4
View File
@@ -187,10 +187,6 @@ func (c ChatFile) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
}
func (c GetChatFileMetadataByChatIDRow) RBACObject() rbac.Object {
return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID)
}
func (s APIKeyScope) ToRBAC() rbac.ScopeName {
switch s {
case ApiKeyScopeCoderAll:
-2
View File
@@ -584,7 +584,6 @@ func (q *sqlQuerier) CountAuthorizedAuditLogs(ctx context.Context, arg CountAudi
arg.DateTo,
arg.BuildReason,
arg.RequestID,
arg.CountCap,
)
if err != nil {
return 0, err
@@ -721,7 +720,6 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
arg.WorkspaceID,
arg.ConnectionID,
arg.Status,
arg.CountCap,
)
if err != nil {
return 0, err
@@ -145,13 +145,5 @@ func extractWhereClause(query string) string {
// Remove SQL comments
whereClause = regexp.MustCompile(`(?m)--.*$`).ReplaceAllString(whereClause, "")
// Normalize indentation so subquery wrapping doesn't cause
// mismatches.
lines := strings.Split(whereClause, "\n")
for i, line := range lines {
lines[i] = strings.TrimLeft(line, " \t")
}
whereClause = strings.Join(lines, "\n")
return strings.TrimSpace(whereClause)
}
-5
View File
@@ -4218,11 +4218,6 @@ type ChatFile struct {
Data []byte `db:"data" json:"data"`
}
type ChatFileLink struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
FileID uuid.UUID `db:"file_id" json:"file_id"`
}
type ChatMessage struct {
ID int64 `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
+3 -18
View File
@@ -244,10 +244,6 @@ type sqlcQuerier interface {
GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error)
GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error)
GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error)
// GetChatFileMetadataByChatID returns lightweight file metadata for
// all files linked to a chat. The data column is excluded to avoid
// loading file content.
GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]GetChatFileMetadataByChatIDRow, error)
GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error)
// GetChatIncludeDefaultSystemPrompt preserves the legacy default
// for deployments created before the explicit include-default toggle.
@@ -782,15 +778,6 @@ type sqlcQuerier interface {
InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error)
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
// LinkChatFiles inserts file associations into the chat_file_links
// join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
// is conditional: it only proceeds when the total number of links
// (existing + genuinely new) does not exceed max_file_links. Returns
// the number of genuinely new file IDs that were NOT inserted due to
// the cap. A return value of 0 means all files were linked (or were
// already linked). A positive value means the cap blocked that many
// new links.
LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, error)
ListAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams) ([]string, error)
ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, error)
// Finds all unique AI Bridge interception telemetry summaries combinations
@@ -870,11 +857,9 @@ type sqlcQuerier interface {
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error)
UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error)
// Bumps the heartbeat timestamp for the given set of chat IDs,
// provided they are still running and owned by the specified
// worker. Returns the IDs that were actually updated so the
// caller can detect stolen or completed chats via set-difference.
UpdateChatHeartbeats(ctx context.Context, arg UpdateChatHeartbeatsParams) ([]uuid.UUID, error)
// Bumps the heartbeat timestamp for a running chat so that other
// replicas know the worker is still alive.
UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error)
UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLabelsByIDParams) (Chat, error)
// Updates the cached injected context parts (AGENTS.md +
// skills) on the chat row. Called only when context changes
+204 -343
View File
@@ -2275,105 +2275,93 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP
}
const countAuditLogs = `-- name: CountAuditLogs :one
SELECT COUNT(*) FROM (
SELECT 1
FROM audit_logs
LEFT JOIN users ON audit_logs.user_id = users.id
LEFT JOIN organizations ON audit_logs.organization_id = organizations.id
-- First join on workspaces to get the initial workspace create
-- to workspace build 1 id. This is because the first create is
-- is a different audit log than subsequent starts.
LEFT JOIN workspaces ON audit_logs.resource_type = 'workspace'
AND audit_logs.resource_id = workspaces.id
-- Get the reason from the build if the resource type
-- is a workspace_build
LEFT JOIN workspace_builds wb_build ON audit_logs.resource_type = 'workspace_build'
AND audit_logs.resource_id = wb_build.id
-- Get the reason from the build #1 if this is the first
-- workspace create.
LEFT JOIN workspace_builds wb_workspace ON audit_logs.resource_type = 'workspace'
AND audit_logs.action = 'create'
AND workspaces.id = wb_workspace.workspace_id
AND wb_workspace.build_number = 1
WHERE
-- Filter resource_type
CASE
WHEN $1::text != '' THEN resource_type = $1::resource_type
ELSE true
END
-- Filter resource_id
AND CASE
WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN resource_id = $2
ELSE true
END
-- Filter organization_id
AND CASE
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.organization_id = $3
ELSE true
END
-- Filter by resource_target
AND CASE
WHEN $4::text != '' THEN resource_target = $4
ELSE true
END
-- Filter action
AND CASE
WHEN $5::text != '' THEN action = $5::audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN $6::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN user_id = $6
ELSE true
END
-- Filter by username
AND CASE
WHEN $7::text != '' THEN user_id = (
SELECT id
FROM users
WHERE lower(username) = lower($7)
AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN $8::text != '' THEN users.email = $8
ELSE true
END
-- Filter by date_from
AND CASE
WHEN $9::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" >= $9
ELSE true
END
-- Filter by date_to
AND CASE
WHEN $10::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" <= $10
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN $11::text != '' THEN COALESCE(wb_build.reason::text, wb_workspace.reason::text) = $11
ELSE true
END
-- Filter request_id
AND CASE
WHEN $12::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.request_id = $12
ELSE true
END
-- Authorize Filter clause will be injected below in CountAuthorizedAuditLogs
-- @authorize_filter
-- Avoid a slow scan on a large table with joins. The caller
-- passes the count cap and we add 1 so the frontend can detect
-- capping and show "... of N+". A cap of 0 means no limit (NULLIF
-- -> NULL + 1 = NULL).
-- NOTE: Parameterizing this so that we can easily change from,
-- e.g., 2000 to 5000. However, use literal NULL (or no LIMIT)
-- here if disabling the capping on a large table permanently.
-- This way the PG planner can plan parallel execution for
-- potential large wins.
LIMIT NULLIF($13::int, 0) + 1
) AS limited_count
SELECT COUNT(*)
FROM audit_logs
LEFT JOIN users ON audit_logs.user_id = users.id
LEFT JOIN organizations ON audit_logs.organization_id = organizations.id
-- First join on workspaces to get the initial workspace create
-- to workspace build 1 id. This is because the first create is
-- is a different audit log than subsequent starts.
LEFT JOIN workspaces ON audit_logs.resource_type = 'workspace'
AND audit_logs.resource_id = workspaces.id
-- Get the reason from the build if the resource type
-- is a workspace_build
LEFT JOIN workspace_builds wb_build ON audit_logs.resource_type = 'workspace_build'
AND audit_logs.resource_id = wb_build.id
-- Get the reason from the build #1 if this is the first
-- workspace create.
LEFT JOIN workspace_builds wb_workspace ON audit_logs.resource_type = 'workspace'
AND audit_logs.action = 'create'
AND workspaces.id = wb_workspace.workspace_id
AND wb_workspace.build_number = 1
WHERE
-- Filter resource_type
CASE
WHEN $1::text != '' THEN resource_type = $1::resource_type
ELSE true
END
-- Filter resource_id
AND CASE
WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN resource_id = $2
ELSE true
END
-- Filter organization_id
AND CASE
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.organization_id = $3
ELSE true
END
-- Filter by resource_target
AND CASE
WHEN $4::text != '' THEN resource_target = $4
ELSE true
END
-- Filter action
AND CASE
WHEN $5::text != '' THEN action = $5::audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN $6::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN user_id = $6
ELSE true
END
-- Filter by username
AND CASE
WHEN $7::text != '' THEN user_id = (
SELECT id
FROM users
WHERE lower(username) = lower($7)
AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN $8::text != '' THEN users.email = $8
ELSE true
END
-- Filter by date_from
AND CASE
WHEN $9::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" >= $9
ELSE true
END
-- Filter by date_to
AND CASE
WHEN $10::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" <= $10
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN $11::text != '' THEN COALESCE(wb_build.reason::text, wb_workspace.reason::text) = $11
ELSE true
END
-- Filter request_id
AND CASE
WHEN $12::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.request_id = $12
ELSE true
END
-- Authorize Filter clause will be injected below in CountAuthorizedAuditLogs
-- @authorize_filter
`
type CountAuditLogsParams struct {
@@ -2389,7 +2377,6 @@ type CountAuditLogsParams struct {
DateTo time.Time `db:"date_to" json:"date_to"`
BuildReason string `db:"build_reason" json:"build_reason"`
RequestID uuid.UUID `db:"request_id" json:"request_id"`
CountCap int32 `db:"count_cap" json:"count_cap"`
}
func (q *sqlQuerier) CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error) {
@@ -2406,7 +2393,6 @@ func (q *sqlQuerier) CountAuditLogs(ctx context.Context, arg CountAuditLogsParam
arg.DateTo,
arg.BuildReason,
arg.RequestID,
arg.CountCap,
)
var count int64
err := row.Scan(&count)
@@ -2903,56 +2889,6 @@ func (q *sqlQuerier) GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFil
return i, err
}
const getChatFileMetadataByChatID = `-- name: GetChatFileMetadataByChatID :many
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
FROM chat_files cf
JOIN chat_file_links cfl ON cfl.file_id = cf.id
WHERE cfl.chat_id = $1::uuid
ORDER BY cf.created_at ASC
`
type GetChatFileMetadataByChatIDRow struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
Mimetype string `db:"mimetype" json:"mimetype"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// GetChatFileMetadataByChatID returns lightweight file metadata for
// all files linked to a chat. The data column is excluded to avoid
// loading file content.
func (q *sqlQuerier) GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]GetChatFileMetadataByChatIDRow, error) {
rows, err := q.db.QueryContext(ctx, getChatFileMetadataByChatID, chatID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetChatFileMetadataByChatIDRow
for rows.Next() {
var i GetChatFileMetadataByChatIDRow
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.OrganizationID,
&i.Name,
&i.Mimetype,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatFilesByIDs = `-- name: GetChatFilesByIDs :many
SELECT id, owner_id, organization_id, created_at, name, mimetype, data FROM chat_files WHERE id = ANY($1::uuid[])
`
@@ -6097,57 +6033,6 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat
return i, err
}
const linkChatFiles = `-- name: LinkChatFiles :one
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = $1::uuid
),
new_links AS (
SELECT $1::uuid AS chat_id, unnest($2::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
FROM new_links nl
WHERE NOT EXISTS (
SELECT 1 FROM chat_file_links cfl
WHERE cfl.chat_id = nl.chat_id AND cfl.file_id = nl.file_id
)
),
inserted AS (
INSERT INTO chat_file_links (chat_id, file_id)
SELECT gn.chat_id, gn.file_id
FROM genuinely_new gn, current c
WHERE c.cnt + (SELECT COUNT(*) FROM genuinely_new) <= $3::int
ON CONFLICT (chat_id, file_id) DO NOTHING
RETURNING file_id
)
SELECT
(SELECT COUNT(*)::int FROM genuinely_new) -
(SELECT COUNT(*)::int FROM inserted) AS rejected_new_files
`
type LinkChatFilesParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
FileIds []uuid.UUID `db:"file_ids" json:"file_ids"`
MaxFileLinks int32 `db:"max_file_links" json:"max_file_links"`
}
// LinkChatFiles inserts file associations into the chat_file_links
// join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
// is conditional: it only proceeds when the total number of links
// (existing + genuinely new) does not exceed max_file_links. Returns
// the number of genuinely new file IDs that were NOT inserted due to
// the cap. A return value of 0 means all files were linked (or were
// already linked). A positive value means the cap blocked that many
// new links.
func (q *sqlQuerier) LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, error) {
row := q.db.QueryRowContext(ctx, linkChatFiles, arg.ChatID, pq.Array(arg.FileIds), arg.MaxFileLinks)
var rejected_new_files int32
err := row.Scan(&rejected_new_files)
return rejected_new_files, err
}
const listChatUsageLimitGroupOverrides = `-- name: ListChatUsageLimitGroupOverrides :many
SELECT
g.id AS group_id,
@@ -6615,49 +6500,30 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
return i, err
}
const updateChatHeartbeats = `-- name: UpdateChatHeartbeats :many
const updateChatHeartbeat = `-- name: UpdateChatHeartbeat :execrows
UPDATE
chats
SET
heartbeat_at = $1::timestamptz
heartbeat_at = NOW()
WHERE
id = ANY($2::uuid[])
AND worker_id = $3::uuid
id = $1::uuid
AND worker_id = $2::uuid
AND status = 'running'::chat_status
RETURNING id
`
type UpdateChatHeartbeatsParams struct {
Now time.Time `db:"now" json:"now"`
IDs []uuid.UUID `db:"ids" json:"ids"`
WorkerID uuid.UUID `db:"worker_id" json:"worker_id"`
type UpdateChatHeartbeatParams struct {
ID uuid.UUID `db:"id" json:"id"`
WorkerID uuid.UUID `db:"worker_id" json:"worker_id"`
}
// Bumps the heartbeat timestamp for the given set of chat IDs,
// provided they are still running and owned by the specified
// worker. Returns the IDs that were actually updated so the
// caller can detect stolen or completed chats via set-difference.
func (q *sqlQuerier) UpdateChatHeartbeats(ctx context.Context, arg UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, updateChatHeartbeats, arg.Now, pq.Array(arg.IDs), arg.WorkerID)
// Bumps the heartbeat timestamp for a running chat so that other
// replicas know the worker is still alive.
func (q *sqlQuerier) UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateChatHeartbeat, arg.ID, arg.WorkerID)
if err != nil {
return nil, err
return 0, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
return result.RowsAffected()
}
const updateChatLabelsByID = `-- name: UpdateChatLabelsByID :one
@@ -7604,113 +7470,110 @@ func (q *sqlQuerier) BatchUpsertConnectionLogs(ctx context.Context, arg BatchUps
}
const countConnectionLogs = `-- name: CountConnectionLogs :one
SELECT COUNT(*) AS count FROM (
SELECT 1
FROM
connection_logs
JOIN users AS workspace_owner ON
connection_logs.workspace_owner_id = workspace_owner.id
LEFT JOIN users ON
connection_logs.user_id = users.id
JOIN organizations ON
connection_logs.organization_id = organizations.id
WHERE
-- Filter organization_id
CASE
WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.organization_id = $1
ELSE true
END
-- Filter by workspace owner username
AND CASE
WHEN $2 :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE lower(username) = lower($2) AND deleted = false
)
ELSE true
END
-- Filter by workspace_owner_id
AND CASE
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_owner_id = $3
ELSE true
END
-- Filter by workspace_owner_email
AND CASE
WHEN $4 :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE email = $4 AND deleted = false
)
ELSE true
END
-- Filter by type
AND CASE
WHEN $5 :: text != '' THEN
type = $5 :: connection_type
ELSE true
END
-- Filter by user_id
AND CASE
WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = $6
ELSE true
END
-- Filter by username
AND CASE
WHEN $7 :: text != '' THEN
user_id = (
SELECT id FROM users
WHERE lower(username) = lower($7) AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN $8 :: text != '' THEN
users.email = $8
ELSE true
END
-- Filter by connected_after
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time >= $9
ELSE true
END
-- Filter by connected_before
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time <= $10
ELSE true
END
-- Filter by workspace_id
AND CASE
WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.workspace_id = $11
ELSE true
END
-- Filter by connection_id
AND CASE
WHEN $12 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.connection_id = $12
ELSE true
END
-- Filter by whether the session has a disconnect_time
AND CASE
WHEN $13 :: text != '' THEN
(($13 = 'ongoing' AND disconnect_time IS NULL) OR
($13 = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
ELSE true
END
-- Authorize Filter clause will be injected below in
-- CountAuthorizedConnectionLogs
-- @authorize_filter
-- NOTE: See the CountAuditLogs LIMIT note.
LIMIT NULLIF($14::int, 0) + 1
) AS limited_count
SELECT
COUNT(*) AS count
FROM
connection_logs
JOIN users AS workspace_owner ON
connection_logs.workspace_owner_id = workspace_owner.id
LEFT JOIN users ON
connection_logs.user_id = users.id
JOIN organizations ON
connection_logs.organization_id = organizations.id
WHERE
-- Filter organization_id
CASE
WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.organization_id = $1
ELSE true
END
-- Filter by workspace owner username
AND CASE
WHEN $2 :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE lower(username) = lower($2) AND deleted = false
)
ELSE true
END
-- Filter by workspace_owner_id
AND CASE
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_owner_id = $3
ELSE true
END
-- Filter by workspace_owner_email
AND CASE
WHEN $4 :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE email = $4 AND deleted = false
)
ELSE true
END
-- Filter by type
AND CASE
WHEN $5 :: text != '' THEN
type = $5 :: connection_type
ELSE true
END
-- Filter by user_id
AND CASE
WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = $6
ELSE true
END
-- Filter by username
AND CASE
WHEN $7 :: text != '' THEN
user_id = (
SELECT id FROM users
WHERE lower(username) = lower($7) AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN $8 :: text != '' THEN
users.email = $8
ELSE true
END
-- Filter by connected_after
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time >= $9
ELSE true
END
-- Filter by connected_before
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time <= $10
ELSE true
END
-- Filter by workspace_id
AND CASE
WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.workspace_id = $11
ELSE true
END
-- Filter by connection_id
AND CASE
WHEN $12 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.connection_id = $12
ELSE true
END
-- Filter by whether the session has a disconnect_time
AND CASE
WHEN $13 :: text != '' THEN
(($13 = 'ongoing' AND disconnect_time IS NULL) OR
($13 = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
ELSE true
END
-- Authorize Filter clause will be injected below in
-- CountAuthorizedConnectionLogs
-- @authorize_filter
`
type CountConnectionLogsParams struct {
@@ -7727,7 +7590,6 @@ type CountConnectionLogsParams struct {
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"`
Status string `db:"status" json:"status"`
CountCap int32 `db:"count_cap" json:"count_cap"`
}
func (q *sqlQuerier) CountConnectionLogs(ctx context.Context, arg CountConnectionLogsParams) (int64, error) {
@@ -7745,7 +7607,6 @@ func (q *sqlQuerier) CountConnectionLogs(ctx context.Context, arg CountConnectio
arg.WorkspaceID,
arg.ConnectionID,
arg.Status,
arg.CountCap,
)
var count int64
err := row.Scan(&count)
+88 -99
View File
@@ -149,105 +149,94 @@ VALUES (
RETURNING *;
-- name: CountAuditLogs :one
SELECT COUNT(*) FROM (
SELECT 1
FROM audit_logs
LEFT JOIN users ON audit_logs.user_id = users.id
LEFT JOIN organizations ON audit_logs.organization_id = organizations.id
-- First join on workspaces to get the initial workspace create
-- to workspace build 1 id. This is because the first create is
-- is a different audit log than subsequent starts.
LEFT JOIN workspaces ON audit_logs.resource_type = 'workspace'
AND audit_logs.resource_id = workspaces.id
-- Get the reason from the build if the resource type
-- is a workspace_build
LEFT JOIN workspace_builds wb_build ON audit_logs.resource_type = 'workspace_build'
AND audit_logs.resource_id = wb_build.id
-- Get the reason from the build #1 if this is the first
-- workspace create.
LEFT JOIN workspace_builds wb_workspace ON audit_logs.resource_type = 'workspace'
AND audit_logs.action = 'create'
AND workspaces.id = wb_workspace.workspace_id
AND wb_workspace.build_number = 1
WHERE
-- Filter resource_type
CASE
WHEN @resource_type::text != '' THEN resource_type = @resource_type::resource_type
ELSE true
END
-- Filter resource_id
AND CASE
WHEN @resource_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN resource_id = @resource_id
ELSE true
END
-- Filter organization_id
AND CASE
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.organization_id = @organization_id
ELSE true
END
-- Filter by resource_target
AND CASE
WHEN @resource_target::text != '' THEN resource_target = @resource_target
ELSE true
END
-- Filter action
AND CASE
WHEN @action::text != '' THEN action = @action::audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username::text != '' THEN user_id = (
SELECT id
FROM users
WHERE lower(username) = lower(@username)
AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @email::text != '' THEN users.email = @email
ELSE true
END
-- Filter by date_from
AND CASE
WHEN @date_from::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" >= @date_from
ELSE true
END
-- Filter by date_to
AND CASE
WHEN @date_to::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" <= @date_to
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN @build_reason::text != '' THEN COALESCE(wb_build.reason::text, wb_workspace.reason::text) = @build_reason
ELSE true
END
-- Filter request_id
AND CASE
WHEN @request_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.request_id = @request_id
ELSE true
END
-- Authorize Filter clause will be injected below in CountAuthorizedAuditLogs
-- @authorize_filter
-- Avoid a slow scan on a large table with joins. The caller
-- passes the count cap and we add 1 so the frontend can detect
-- capping and show "... of N+". A cap of 0 means no limit (NULLIF
-- -> NULL + 1 = NULL).
-- NOTE: Parameterizing this so that we can easily change from,
-- e.g., 2000 to 5000. However, use literal NULL (or no LIMIT)
-- here if disabling the capping on a large table permanently.
-- This way the PG planner can plan parallel execution for
-- potential large wins.
LIMIT NULLIF(@count_cap::int, 0) + 1
) AS limited_count;
SELECT COUNT(*)
FROM audit_logs
LEFT JOIN users ON audit_logs.user_id = users.id
LEFT JOIN organizations ON audit_logs.organization_id = organizations.id
-- First join on workspaces to get the initial workspace create
-- to workspace build 1 id. This is because the first create is
-- is a different audit log than subsequent starts.
LEFT JOIN workspaces ON audit_logs.resource_type = 'workspace'
AND audit_logs.resource_id = workspaces.id
-- Get the reason from the build if the resource type
-- is a workspace_build
LEFT JOIN workspace_builds wb_build ON audit_logs.resource_type = 'workspace_build'
AND audit_logs.resource_id = wb_build.id
-- Get the reason from the build #1 if this is the first
-- workspace create.
LEFT JOIN workspace_builds wb_workspace ON audit_logs.resource_type = 'workspace'
AND audit_logs.action = 'create'
AND workspaces.id = wb_workspace.workspace_id
AND wb_workspace.build_number = 1
WHERE
-- Filter resource_type
CASE
WHEN @resource_type::text != '' THEN resource_type = @resource_type::resource_type
ELSE true
END
-- Filter resource_id
AND CASE
WHEN @resource_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN resource_id = @resource_id
ELSE true
END
-- Filter organization_id
AND CASE
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.organization_id = @organization_id
ELSE true
END
-- Filter by resource_target
AND CASE
WHEN @resource_target::text != '' THEN resource_target = @resource_target
ELSE true
END
-- Filter action
AND CASE
WHEN @action::text != '' THEN action = @action::audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username::text != '' THEN user_id = (
SELECT id
FROM users
WHERE lower(username) = lower(@username)
AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @email::text != '' THEN users.email = @email
ELSE true
END
-- Filter by date_from
AND CASE
WHEN @date_from::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" >= @date_from
ELSE true
END
-- Filter by date_to
AND CASE
WHEN @date_to::timestamp with time zone != '0001-01-01 00:00:00Z' THEN "time" <= @date_to
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN @build_reason::text != '' THEN COALESCE(wb_build.reason::text, wb_workspace.reason::text) = @build_reason
ELSE true
END
-- Filter request_id
AND CASE
WHEN @request_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN audit_logs.request_id = @request_id
ELSE true
END
-- Authorize Filter clause will be injected below in CountAuthorizedAuditLogs
-- @authorize_filter
;
-- name: DeleteOldAuditLogConnectionEvents :exec
DELETE FROM audit_logs
-10
View File
@@ -8,13 +8,3 @@ SELECT * FROM chat_files WHERE id = @id::uuid;
-- name: GetChatFilesByIDs :many
SELECT * FROM chat_files WHERE id = ANY(@ids::uuid[]);
-- name: GetChatFileMetadataByChatID :many
-- GetChatFileMetadataByChatID returns lightweight file metadata for
-- all files linked to a chat. The data column is excluded to avoid
-- loading file content.
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
FROM chat_files cf
JOIN chat_file_links cfl ON cfl.file_id = cf.id
WHERE cfl.chat_id = @chat_id::uuid
ORDER BY cf.created_at ASC;
+6 -46
View File
@@ -567,43 +567,6 @@ WHERE
RETURNING
*;
-- name: LinkChatFiles :one
-- LinkChatFiles inserts file associations into the chat_file_links
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
-- is conditional: it only proceeds when the total number of links
-- (existing + genuinely new) does not exceed max_file_links. Returns
-- the number of genuinely new file IDs that were NOT inserted due to
-- the cap. A return value of 0 means all files were linked (or were
-- already linked). A positive value means the cap blocked that many
-- new links.
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = @chat_id::uuid
),
new_links AS (
SELECT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
FROM new_links nl
WHERE NOT EXISTS (
SELECT 1 FROM chat_file_links cfl
WHERE cfl.chat_id = nl.chat_id AND cfl.file_id = nl.file_id
)
),
inserted AS (
INSERT INTO chat_file_links (chat_id, file_id)
SELECT gn.chat_id, gn.file_id
FROM genuinely_new gn, current c
WHERE c.cnt + (SELECT COUNT(*) FROM genuinely_new) <= @max_file_links::int
ON CONFLICT (chat_id, file_id) DO NOTHING
RETURNING file_id
)
SELECT
(SELECT COUNT(*)::int FROM genuinely_new) -
(SELECT COUNT(*)::int FROM inserted) AS rejected_new_files;
-- name: AcquireChats :many
-- Acquires up to @num_chats pending chats for processing. Uses SKIP LOCKED
-- to prevent multiple replicas from acquiring the same chat.
@@ -674,20 +637,17 @@ WHERE
status = 'running'::chat_status
AND heartbeat_at < @stale_threshold::timestamptz;
-- name: UpdateChatHeartbeats :many
-- Bumps the heartbeat timestamp for the given set of chat IDs,
-- provided they are still running and owned by the specified
-- worker. Returns the IDs that were actually updated so the
-- caller can detect stolen or completed chats via set-difference.
-- name: UpdateChatHeartbeat :execrows
-- Bumps the heartbeat timestamp for a running chat so that other
-- replicas know the worker is still alive.
UPDATE
chats
SET
heartbeat_at = @now::timestamptz
heartbeat_at = NOW()
WHERE
id = ANY(@ids::uuid[])
id = @id::uuid
AND worker_id = @worker_id::uuid
AND status = 'running'::chat_status
RETURNING id;
AND status = 'running'::chat_status;
-- name: GetChatDiffStatusByChatID :one
SELECT
+105 -107
View File
@@ -133,113 +133,111 @@ OFFSET
@offset_opt;
-- name: CountConnectionLogs :one
SELECT COUNT(*) AS count FROM (
SELECT 1
FROM
connection_logs
JOIN users AS workspace_owner ON
connection_logs.workspace_owner_id = workspace_owner.id
LEFT JOIN users ON
connection_logs.user_id = users.id
JOIN organizations ON
connection_logs.organization_id = organizations.id
WHERE
-- Filter organization_id
CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.organization_id = @organization_id
ELSE true
END
-- Filter by workspace owner username
AND CASE
WHEN @workspace_owner :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE lower(username) = lower(@workspace_owner) AND deleted = false
)
ELSE true
END
-- Filter by workspace_owner_id
AND CASE
WHEN @workspace_owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_owner_id = @workspace_owner_id
ELSE true
END
-- Filter by workspace_owner_email
AND CASE
WHEN @workspace_owner_email :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE email = @workspace_owner_email AND deleted = false
)
ELSE true
END
-- Filter by type
AND CASE
WHEN @type :: text != '' THEN
type = @type :: connection_type
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username :: text != '' THEN
user_id = (
SELECT id FROM users
WHERE lower(username) = lower(@username) AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @user_email :: text != '' THEN
users.email = @user_email
ELSE true
END
-- Filter by connected_after
AND CASE
WHEN @connected_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time >= @connected_after
ELSE true
END
-- Filter by connected_before
AND CASE
WHEN @connected_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time <= @connected_before
ELSE true
END
-- Filter by workspace_id
AND CASE
WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.workspace_id = @workspace_id
ELSE true
END
-- Filter by connection_id
AND CASE
WHEN @connection_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.connection_id = @connection_id
ELSE true
END
-- Filter by whether the session has a disconnect_time
AND CASE
WHEN @status :: text != '' THEN
((@status = 'ongoing' AND disconnect_time IS NULL) OR
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
ELSE true
END
-- Authorize Filter clause will be injected below in
-- CountAuthorizedConnectionLogs
-- @authorize_filter
-- NOTE: See the CountAuditLogs LIMIT note.
LIMIT NULLIF(@count_cap::int, 0) + 1
) AS limited_count;
SELECT
COUNT(*) AS count
FROM
connection_logs
JOIN users AS workspace_owner ON
connection_logs.workspace_owner_id = workspace_owner.id
LEFT JOIN users ON
connection_logs.user_id = users.id
JOIN organizations ON
connection_logs.organization_id = organizations.id
WHERE
-- Filter organization_id
CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.organization_id = @organization_id
ELSE true
END
-- Filter by workspace owner username
AND CASE
WHEN @workspace_owner :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE lower(username) = lower(@workspace_owner) AND deleted = false
)
ELSE true
END
-- Filter by workspace_owner_id
AND CASE
WHEN @workspace_owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
workspace_owner_id = @workspace_owner_id
ELSE true
END
-- Filter by workspace_owner_email
AND CASE
WHEN @workspace_owner_email :: text != '' THEN
workspace_owner_id = (
SELECT id FROM users
WHERE email = @workspace_owner_email AND deleted = false
)
ELSE true
END
-- Filter by type
AND CASE
WHEN @type :: text != '' THEN
type = @type :: connection_type
ELSE true
END
-- Filter by user_id
AND CASE
WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = @user_id
ELSE true
END
-- Filter by username
AND CASE
WHEN @username :: text != '' THEN
user_id = (
SELECT id FROM users
WHERE lower(username) = lower(@username) AND deleted = false
)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN @user_email :: text != '' THEN
users.email = @user_email
ELSE true
END
-- Filter by connected_after
AND CASE
WHEN @connected_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time >= @connected_after
ELSE true
END
-- Filter by connected_before
AND CASE
WHEN @connected_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
connect_time <= @connected_before
ELSE true
END
-- Filter by workspace_id
AND CASE
WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.workspace_id = @workspace_id
ELSE true
END
-- Filter by connection_id
AND CASE
WHEN @connection_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
connection_logs.connection_id = @connection_id
ELSE true
END
-- Filter by whether the session has a disconnect_time
AND CASE
WHEN @status :: text != '' THEN
((@status = 'ongoing' AND disconnect_time IS NULL) OR
(@status = 'completed' AND disconnect_time IS NOT NULL)) AND
-- Exclude web events, since we don't know their close time.
"type" NOT IN ('workspace_app', 'port_forwarding')
ELSE true
END
-- Authorize Filter clause will be injected below in
-- CountAuthorizedConnectionLogs
-- @authorize_filter
;
-- name: DeleteOldConnectionLogs :execrows
WITH old_logs AS (
-1
View File
@@ -247,7 +247,6 @@ sql:
mcp_server_tool_snapshots: MCPServerToolSnapshots
mcp_server_config_id: MCPServerConfigID
mcp_server_ids: MCPServerIDs
max_file_links: MaxFileLinks
icon_url: IconURL
oauth2_client_id: OAuth2ClientID
oauth2_client_secret: OAuth2ClientSecret
-1
View File
@@ -16,7 +16,6 @@ const (
UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id);
UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id);
UniqueChatFileLinksChatIDFileIDKey UniqueConstraint = "chat_file_links_chat_id_file_id_key" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id);
UniqueChatFilesPkey UniqueConstraint = "chat_files_pkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id);
UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id);
UniqueChatModelConfigsPkey UniqueConstraint = "chat_model_configs_pkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id);
+19 -139
View File
@@ -413,7 +413,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, titleSource, fileIDs, inputError := createChatInputFromRequest(ctx, api.Database, req)
contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
return
@@ -524,32 +524,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
// Link any user-uploaded files referenced in the initial
// message to this newly created chat (best-effort; cap
// enforced in SQL).
unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, fileIDs)
// Re-read the chat so the response reflects the authoritative
// database state (file links are deduped in the join table).
chat, err = api.Database.GetChatByID(ctx, chat.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to read back chat after creation.",
Detail: err.Error(),
})
return
}
chatFiles := api.fetchChatFileMetadata(ctx, chat.ID)
response := db2sdk.Chat(chat, nil, chatFiles)
if len(unlinked) > 0 {
if capExceeded {
response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked)))
} else {
response.Warnings = append(response.Warnings, fileLinkErrorWarning(len(unlinked)))
}
}
httpapi.Write(ctx, rw, http.StatusCreated, response)
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Chat(chat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -1326,11 +1301,7 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
slog.Error(err),
)
}
// Hydrate file metadata for all files linked to this chat.
chatFiles := api.fetchChatFileMetadata(ctx, chat.ID)
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, diffStatus, chatFiles))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, diffStatus))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -1820,7 +1791,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, _, fileIDs, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
@@ -1902,9 +1873,6 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
return
}
// Link any user-uploaded files referenced in this message
// to the chat (best-effort; cap enforced in SQL).
unlinked, capExceeded := api.linkFilesToChat(ctx, chatID, fileIDs)
response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued}
if sendResult.Queued {
if sendResult.QueuedMessage != nil {
@@ -1914,13 +1882,6 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
message := convertChatMessage(sendResult.Message)
response.Message = &message
}
if len(unlinked) > 0 {
if capExceeded {
response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked)))
} else {
response.Warnings = append(response.Warnings, fileLinkErrorWarning(len(unlinked)))
}
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
@@ -1954,7 +1915,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, _, fileIDs, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
@@ -1993,20 +1954,8 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
// Link any user-uploaded files referenced in the edited
// message to the chat (best-effort; cap enforced in SQL).
unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, fileIDs)
response := codersdk.EditChatMessageResponse{
Message: convertChatMessage(editResult.Message),
}
if len(unlinked) > 0 {
if capExceeded {
response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked)))
} else {
response.Warnings = append(response.Warnings, fileLinkErrorWarning(len(unlinked)))
}
}
httpapi.Write(ctx, rw, http.StatusOK, response)
message := convertChatMessage(editResult.Message)
httpapi.Write(ctx, rw, http.StatusOK, message)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -2283,7 +2232,7 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
chat = updatedChat
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, nil, nil))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -2327,7 +2276,7 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
@@ -3739,7 +3688,6 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
func createChatInputFromRequest(ctx context.Context, db database.Store, req codersdk.CreateChatRequest) (
[]codersdk.ChatMessagePart,
string,
[]uuid.UUID,
*codersdk.Response,
) {
return createChatInputFromParts(ctx, db, req.Content, "content")
@@ -3750,15 +3698,14 @@ func createChatInputFromParts(
db database.Store,
parts []codersdk.ChatInputPart,
fieldName string,
) ([]codersdk.ChatMessagePart, string, []uuid.UUID, *codersdk.Response) {
) ([]codersdk.ChatMessagePart, string, *codersdk.Response) {
if len(parts) == 0 {
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Content is required.",
Detail: "Content cannot be empty.",
}
}
var fileIDs []uuid.UUID
content := make([]codersdk.ChatMessagePart, 0, len(parts))
textParts := make([]string, 0, len(parts))
for i, part := range parts {
@@ -3766,7 +3713,7 @@ func createChatInputFromParts(
case string(codersdk.ChatInputPartTypeText):
text := strings.TrimSpace(part.Text)
if text == "" {
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i),
}
@@ -3775,7 +3722,7 @@ func createChatInputFromParts(
textParts = append(textParts, text)
case string(codersdk.ChatInputPartTypeFile):
if part.FileID == uuid.Nil {
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id is required for file parts.", fieldName, i),
}
@@ -3786,23 +3733,20 @@ func createChatInputFromParts(
chatFile, err := db.GetChatFileByID(ctx, part.FileID)
if err != nil {
if httpapi.Is404Error(err) {
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file that does not exist.", fieldName, i),
}
}
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Internal error.",
Detail: fmt.Sprintf("Failed to retrieve file for %s[%d].", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype))
fileIDs = append(fileIDs, part.FileID)
// file-reference parts carry inline code snippets, not uploaded
// files. They have no FileID and are excluded from file tracking.
case string(codersdk.ChatInputPartTypeFileReference):
if part.FileName == "" {
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i),
}
@@ -3820,7 +3764,7 @@ func createChatInputFromParts(
}
textParts = append(textParts, sb.String())
default:
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf(
"%s[%d].type %q is not supported.",
@@ -3835,13 +3779,13 @@ func createChatInputFromParts(
// Allow file-only messages. The titleSource may be empty
// when only file parts are provided, callers handle this.
if len(content) == 0 {
return nil, "", nil, &codersdk.Response{
return nil, "", &codersdk.Response{
Message: "Content is required.",
Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName),
}
}
titleSource := strings.TrimSpace(strings.Join(textParts, " "))
return content, titleSource, fileIDs, nil
return content, titleSource, nil
}
func chatTitleFromMessage(message string) string {
@@ -3876,70 +3820,6 @@ func truncateRunes(value string, maxLen int) string {
return string(runes[:maxLen])
}
// linkFilesToChat inserts file-link rows into the chat_file_links
// join table. Cap enforcement and dedup are handled atomically in
// SQL. On success returns (nil, false). On failure returns the full
// input fileIDs slice — linking is all-or-nothing because the
// SQL operates on the batch atomically. capExceeded indicates
// whether the failure was due to the cap being exceeded (true)
// or a database error (false).
// Failures are logged but never block the caller.
func (api *API) linkFilesToChat(ctx context.Context, chatID uuid.UUID, fileIDs []uuid.UUID) (unlinked []uuid.UUID, capExceeded bool) {
if len(fileIDs) == 0 {
return nil, false
}
rejected, err := api.Database.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: fileIDs,
})
if err != nil {
api.Logger.Error(ctx, "failed to link files to chat",
slog.F("chat_id", chatID),
slog.F("file_ids", fileIDs),
slog.Error(err),
)
return fileIDs, false
}
if rejected > 0 {
api.Logger.Warn(ctx, "file cap reached, files not linked",
slog.F("chat_id", chatID),
slog.F("file_ids", fileIDs),
slog.F("max_file_links", codersdk.MaxChatFileIDs),
)
return fileIDs, true
}
return nil, false
}
// fileLinkCapWarning builds a user-facing warning when a batch
// of file IDs was atomically rejected because the resulting
// array would exceed the per-chat file cap.
func fileLinkCapWarning(count int) string {
return fmt.Sprintf("file linking skipped: batch of %d file(s) would exceed limit of %d", count, codersdk.MaxChatFileIDs)
}
// fileLinkErrorWarning builds a user-facing warning when a
// database error prevented linking files to a chat.
func fileLinkErrorWarning(count int) string {
return fmt.Sprintf("%d file(s) could not be linked due to a server error", count)
}
// fetchChatFileMetadata returns metadata for all files linked to
// the given chat. Errors are logged and result in a nil return
// (callers treat file metadata as best-effort).
func (api *API) fetchChatFileMetadata(ctx context.Context, chatID uuid.UUID) []database.GetChatFileMetadataByChatIDRow {
rows, err := api.Database.GetChatFileMetadataByChatID(ctx, chatID)
if err != nil {
api.Logger.Error(ctx, "failed to fetch chat file metadata",
slog.F("chat_id", chatID),
slog.Error(err),
)
return nil
}
return rows
}
func convertChatCostModelBreakdown(model database.GetChatCostPerModelRow) codersdk.ChatCostModelBreakdown {
displayName := strings.TrimSpace(model.DisplayName)
if displayName == "" {
+5 -449
View File
@@ -3227,153 +3227,6 @@ func TestGetChat(t *testing.T) {
_, err = otherClient.GetChat(ctx, createdChat.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("FilesHydrated", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "hydrated.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Create a chat with a text + file part.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "check file hydration"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
// GET the chat — files must be hydrated with all metadata fields.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1)
f := chatResult.Files[0]
require.Equal(t, uploadResp.ID, f.ID)
require.Equal(t, firstUser.UserID, f.OwnerID)
require.NotEqual(t, uuid.Nil, f.OrganizationID)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, "hydrated.png", f.Name)
require.NotZero(t, f.CreatedAt)
})
// ToolCreatedFilesLinked exercises the DB path that chatd uses
// when a tool (e.g. propose_plan) creates a file: InsertChatFile
// then LinkChatFiles. This is a DB-level test because driving
// the full chatd tool-call pipeline requires an LLM mock.
t.Run("ToolCreatedFilesLinked", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, store := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Create a chat via the API so all metadata is set up.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "tool file test"},
},
})
require.NoError(t, err)
// Mimic what chatd's StoreFile closure does:
// 1. InsertChatFile
// 2. LinkChatFiles
//nolint:gocritic // Using AsChatd to mimic the chatd background worker.
chatdCtx := dbauthz.AsChatd(ctx)
fileRow, err := store.InsertChatFile(chatdCtx, database.InsertChatFileParams{
OwnerID: firstUser.UserID,
OrganizationID: firstUser.OrganizationID,
Name: "plan.md",
Mimetype: "text/markdown",
Data: []byte("# Plan"),
})
require.NoError(t, err)
rejected, err := store.LinkChatFiles(chatdCtx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileRow.ID},
})
require.NoError(t, err)
require.Equal(t, int32(0), rejected, "0 rejected = all files linked")
// Verify via the API that the file appears in the chat.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1)
f := chatResult.Files[0]
require.Equal(t, fileRow.ID, f.ID)
require.Equal(t, firstUser.UserID, f.OwnerID)
require.Equal(t, firstUser.OrganizationID, f.OrganizationID)
require.Equal(t, "plan.md", f.Name)
require.Equal(t, "text/markdown", f.MimeType)
// Fill up to the cap by inserting more files via the
// chatd DB path, then verify the cap is enforced.
for i := 1; i < codersdk.MaxChatFileIDs; i++ {
extra, err := store.InsertChatFile(chatdCtx, database.InsertChatFileParams{
OwnerID: firstUser.UserID,
OrganizationID: firstUser.OrganizationID,
Name: fmt.Sprintf("file%d.md", i),
Mimetype: "text/markdown",
Data: []byte("data"),
})
require.NoError(t, err)
_, err = store.LinkChatFiles(chatdCtx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{extra.ID},
})
require.NoError(t, err)
}
// Chat should now have exactly MaxChatFileIDs files.
chatResult, err = client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, codersdk.MaxChatFileIDs)
// Attempt to add one more file — should be rejected (0 rows).
overflow, err := store.InsertChatFile(chatdCtx, database.InsertChatFileParams{
OwnerID: firstUser.UserID,
OrganizationID: firstUser.OrganizationID,
Name: "overflow.md",
Mimetype: "text/markdown",
Data: []byte("too many"),
})
require.NoError(t, err)
rejected, err = store.LinkChatFiles(chatdCtx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{overflow.ID},
})
require.NoError(t, err)
require.Equal(t, int32(1), rejected, "cap should reject the 21st file")
// Re-appending an already-linked ID at cap should succeed
// (dedup means no array growth).
rejected, err = store.LinkChatFiles(chatdCtx, database.LinkChatFilesParams{
ChatID: chat.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{fileRow.ID},
})
require.NoError(t, err)
// ON CONFLICT DO NOTHING returns 0 rows when the link
// already exists, which is fine — the file is still linked.
require.Equal(t, int32(0), rejected, "dedup of existing ID should be a no-op")
// Count should still be exactly MaxChatFileIDs.
chatResult, err = client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, codersdk.MaxChatFileIDs)
})
}
func TestArchiveChat(t *testing.T) {
@@ -4520,14 +4373,6 @@ func TestChatMessageWithFiles(t *testing.T) {
// With no text, chatTitleFromMessage("") returns "New Chat".
require.Equal(t, "New Chat", chat.Title)
require.Len(t, chat.Files, 1)
f := chat.Files[0]
require.Equal(t, uploadResp.ID, f.ID)
require.Equal(t, firstUser.UserID, f.OwnerID)
require.NotEqual(t, uuid.Nil, f.OrganizationID)
require.Equal(t, "image/png", f.MimeType)
require.Equal(t, "test.png", f.Name)
require.NotZero(t, f.CreatedAt)
})
t.Run("InvalidFileID", func(t *testing.T) {
@@ -4562,189 +4407,6 @@ func TestChatMessageWithFiles(t *testing.T) {
require.Equal(t, "Invalid input part.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, "does not exist")
})
t.Run("FilesLinkedOnSend", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Create a text-only chat (no files initially).
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "no files yet"},
},
})
require.NoError(t, err)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "linked.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Send a message with the file.
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "here is a file"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
// GET the chat — file should be linked.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1)
require.Equal(t, uploadResp.ID, chatResult.Files[0].ID)
require.Equal(t, "linked.png", chatResult.Files[0].Name)
})
t.Run("DedupFileIDs", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "dedup.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Create a chat with a file.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "first mention"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
// Send another message with the SAME file.
msgResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "same file again"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
require.Empty(t, msgResp.Warnings, "dedup below cap should not produce warnings")
// GET — should have exactly 1 file (deduped by SQL DISTINCT).
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1, "duplicate file IDs should be deduped")
require.Equal(t, uploadResp.ID, chatResult.Files[0].ID)
})
t.Run("FileCapExceeded", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
// Upload MaxChatFileIDs files.
fileIDs := make([]uuid.UUID, 0, codersdk.MaxChatFileIDs)
for i := range codersdk.MaxChatFileIDs {
resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", fmt.Sprintf("file%d.png", i), bytes.NewReader(pngData))
require.NoError(t, err)
fileIDs = append(fileIDs, resp.ID)
}
// Create a chat using all MaxChatFileIDs files.
parts := []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "max files"},
}
for _, fid := range fileIDs {
parts = append(parts, codersdk.ChatInputPart{Type: codersdk.ChatInputPartTypeFile, FileID: fid})
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{Content: parts})
require.NoError(t, err)
require.Empty(t, chat.Warnings, "creating a chat at exactly the cap should not warn")
require.Len(t, chat.Files, codersdk.MaxChatFileIDs, "all files should be linked on creation")
// Upload one more file.
extraResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "one-too-many.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Sending a message with the extra file should succeed
// (message goes through) but the file should NOT be linked
// (cap enforced in SQL). The response includes a warning.
msgResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "one too many"},
{Type: codersdk.ChatInputPartTypeFile, FileID: extraResp.ID},
},
})
require.NoError(t, err)
require.NotEmpty(t, msgResp.Warnings, "response should warn about unlinked files")
require.Contains(t, msgResp.Warnings[0], "file linking skipped")
// The extra file should NOT appear in the chat's files.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, codersdk.MaxChatFileIDs,
"file count should not exceed the cap")
// Sending a message referencing an already-linked file
// should succeed with no warnings (dedup, no array growth).
msgResp2, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "re-reference existing"},
{Type: codersdk.ChatInputPartTypeFile, FileID: fileIDs[0]},
},
})
require.NoError(t, err)
require.Empty(t, msgResp2.Warnings, "re-referencing an existing file should not warn")
})
t.Run("FileCapOnCreate", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
// Upload MaxChatFileIDs + 1 files.
fileIDs := make([]uuid.UUID, 0, codersdk.MaxChatFileIDs+1)
for i := range codersdk.MaxChatFileIDs + 1 {
resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", fmt.Sprintf("create%d.png", i), bytes.NewReader(pngData))
require.NoError(t, err)
fileIDs = append(fileIDs, resp.ID)
}
// Create a chat with all files (one over the cap).
parts := []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "over cap on create"},
}
for _, fid := range fileIDs {
parts = append(parts, codersdk.ChatInputPart{Type: codersdk.ChatInputPartTypeFile, FileID: fid})
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{Content: parts})
require.NoError(t, err, "chat creation should succeed even when cap is exceeded")
require.NotEmpty(t, chat.Warnings, "response should warn about unlinked files")
require.Contains(t, chat.Warnings[0], "file linking skipped")
// Only MaxChatFileIDs files should actually be linked.
// With SQL-level batch rejection, ALL files are rejected
// when the result would exceed the cap. Since we're
// sending MaxChatFileIDs+1 files, the deduped count is
// 21 > 20, so 0 rows are affected and all files are
// unlinked.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Empty(t, chatResult.Files, "no files should be linked when batch exceeds cap")
})
}
func TestPatchChatMessage(t *testing.T) {
@@ -4791,11 +4453,11 @@ func TestPatchChatMessage(t *testing.T) {
require.NoError(t, err)
// The edited message is soft-deleted and a new one is inserted,
// so the returned ID will differ from the original.
require.NotEqual(t, userMessageID, edited.Message.ID)
require.Equal(t, codersdk.ChatMessageRoleUser, edited.Message.Role)
require.NotEqual(t, userMessageID, edited.ID)
require.Equal(t, codersdk.ChatMessageRoleUser, edited.Role)
foundEditedText := false
for _, part := range edited.Message.Content {
for _, part := range edited.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "hello after edit" {
foundEditedText = true
}
@@ -4883,11 +4545,11 @@ func TestPatchChatMessage(t *testing.T) {
require.NoError(t, err)
// The edited message is soft-deleted and a new one is inserted,
// so the returned ID will differ from the original.
require.NotEqual(t, userMessageID, edited.Message.ID)
require.NotEqual(t, userMessageID, edited.ID)
// Assert the edit response preserves the file_id.
var foundText, foundFile bool
for _, part := range edited.Message.Content {
for _, part := range edited.Content {
if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "after edit with file" {
foundText = true
}
@@ -5030,112 +4692,6 @@ func TestPatchChatMessage(t *testing.T) {
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid chat message ID.", sdkErr.Message)
})
t.Run("FilesLinkedOnEdit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Create a text-only chat.
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "before file edit"},
},
})
require.NoError(t, err)
// Upload a file.
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
uploadResp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "edit-linked.png", bytes.NewReader(pngData))
require.NoError(t, err)
// Find the user message ID.
messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var userMessageID int64
for _, msg := range messagesResult.Messages {
if msg.Role == codersdk.ChatMessageRoleUser {
userMessageID = msg.ID
break
}
}
require.NotZero(t, userMessageID)
// Edit the message to include the file.
_, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "after file edit"},
{Type: codersdk.ChatInputPartTypeFile, FileID: uploadResp.ID},
},
})
require.NoError(t, err)
// GET the chat — file should be linked.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, 1)
f := chatResult.Files[0]
require.Equal(t, uploadResp.ID, f.ID)
require.Equal(t, "edit-linked.png", f.Name)
require.Equal(t, "image/png", f.MimeType)
})
t.Run("CapExceededOnEdit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
// Create a chat with MaxChatFileIDs files already linked.
parts := []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "fill to cap"},
}
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
for i := range codersdk.MaxChatFileIDs {
up, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", fmt.Sprintf("cap-%d.png", i), bytes.NewReader(pngData))
require.NoError(t, err)
parts = append(parts, codersdk.ChatInputPart{Type: codersdk.ChatInputPartTypeFile, FileID: up.ID})
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{Content: parts})
require.NoError(t, err)
require.Empty(t, chat.Warnings, "all files should link on create")
// Find the user message.
messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var userMessageID int64
for _, msg := range messagesResult.Messages {
if msg.Role == codersdk.ChatMessageRoleUser {
userMessageID = msg.ID
break
}
}
require.NotZero(t, userMessageID)
// Upload one more file and try to link via edit.
extra, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "one-too-many.png", bytes.NewReader(pngData))
require.NoError(t, err)
edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "edit with extra file"},
{Type: codersdk.ChatInputPartTypeFile, FileID: extra.ID},
},
})
require.NoError(t, err)
require.NotEmpty(t, edited.Warnings, "edit should surface cap warning")
require.Contains(t, edited.Warnings[0], "file linking skipped")
// Verify the cap is still enforced.
chatResult, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, chatResult.Files, codersdk.MaxChatFileIDs,
"file count should not exceed the cap")
})
}
func TestStreamChat(t *testing.T) {
-34
View File
@@ -298,40 +298,6 @@ neq(input.object.owner, "");
ExpectedSQL: p("'' = 'org-id'"),
VariableConverter: regosql.ChatConverter(),
},
{
Name: "AuditLogUUID",
Queries: []string{
`"8c0b9bdc-a013-4b14-a49b-5747bc335708" = input.object.org_owner`,
`input.object.org_owner != ""`,
`neq(input.object.org_owner, "8c0b9bdc-a013-4b14-a49b-5747bc335708")`,
`input.object.org_owner in {"8c0b9bdc-a013-4b14-a49b-5747bc335708", "05f58202-4bfc-43ce-9ba4-5ff6e0174a71"}`,
`"read" in input.object.acl_group_list[input.object.org_owner]`,
},
ExpectedSQL: p(
p("audit_logs.organization_id = '8c0b9bdc-a013-4b14-a49b-5747bc335708'::uuid") + " OR " +
p("audit_logs.organization_id IS NOT NULL") + " OR " +
p("audit_logs.organization_id != '8c0b9bdc-a013-4b14-a49b-5747bc335708'::uuid") + " OR " +
p("audit_logs.organization_id = ANY(ARRAY ['05f58202-4bfc-43ce-9ba4-5ff6e0174a71'::uuid,'8c0b9bdc-a013-4b14-a49b-5747bc335708'::uuid])") + " OR " +
"(false)"),
VariableConverter: regosql.AuditLogConverter(),
},
{
Name: "ConnectionLogUUID",
Queries: []string{
`"8c0b9bdc-a013-4b14-a49b-5747bc335708" = input.object.org_owner`,
`input.object.org_owner != ""`,
`neq(input.object.org_owner, "8c0b9bdc-a013-4b14-a49b-5747bc335708")`,
`input.object.org_owner in {"8c0b9bdc-a013-4b14-a49b-5747bc335708"}`,
`"read" in input.object.acl_group_list[input.object.org_owner]`,
},
ExpectedSQL: p(
p("connection_logs.organization_id = '8c0b9bdc-a013-4b14-a49b-5747bc335708'::uuid") + " OR " +
p("connection_logs.organization_id IS NOT NULL") + " OR " +
p("connection_logs.organization_id != '8c0b9bdc-a013-4b14-a49b-5747bc335708'::uuid") + " OR " +
p("connection_logs.organization_id = ANY(ARRAY ['8c0b9bdc-a013-4b14-a49b-5747bc335708'::uuid])") + " OR " +
"(false)"),
VariableConverter: regosql.ConnectionLogConverter(),
},
}
for _, tc := range testCases {
+2 -2
View File
@@ -53,7 +53,7 @@ func WorkspaceConverter() *sqltypes.VariableConverter {
func AuditLogConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.UUIDVarMatcher("audit_logs.organization_id", []string{"input", "object", "org_owner"}),
sqltypes.StringVarMatcher("COALESCE(audit_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}),
// Audit logs have no user owner, only owner by an organization.
sqltypes.AlwaysFalse(userOwnerMatcher()),
)
@@ -67,7 +67,7 @@ func AuditLogConverter() *sqltypes.VariableConverter {
func ConnectionLogConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
sqltypes.UUIDVarMatcher("connection_logs.organization_id", []string{"input", "object", "org_owner"}),
sqltypes.StringVarMatcher("COALESCE(connection_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}),
// Connection logs have no user owner, only owner by an organization.
sqltypes.AlwaysFalse(userOwnerMatcher()),
)
-114
View File
@@ -1,114 +0,0 @@
package sqltypes
import (
"fmt"
"strings"
"github.com/open-policy-agent/opa/ast"
"golang.org/x/xerrors"
)
var (
_ VariableMatcher = astUUIDVar{}
_ Node = astUUIDVar{}
_ SupportsEquality = astUUIDVar{}
)
// astUUIDVar is a variable that represents a UUID column. Unlike
// astStringVar it emits native UUID comparisons (column = 'val'::uuid)
// instead of text-based ones (COALESCE(column::text, ”) = 'val').
// This allows PostgreSQL to use indexes on UUID columns.
type astUUIDVar struct {
Source RegoSource
FieldPath []string
ColumnString string
}
func UUIDVarMatcher(sqlColumn string, regoPath []string) VariableMatcher {
return astUUIDVar{FieldPath: regoPath, ColumnString: sqlColumn}
}
func (astUUIDVar) UseAs() Node { return astUUIDVar{} }
func (u astUUIDVar) ConvertVariable(rego ast.Ref) (Node, bool) {
left, err := RegoVarPath(u.FieldPath, rego)
if err == nil && len(left) == 0 {
return astUUIDVar{
Source: RegoSource(rego.String()),
FieldPath: u.FieldPath,
ColumnString: u.ColumnString,
}, true
}
return nil, false
}
func (u astUUIDVar) SQLString(_ *SQLGenerator) string {
return u.ColumnString
}
// EqualsSQLString handles equality comparisons for UUID columns.
// Rego always produces string literals, so we accept AstString and
// cast the literal to ::uuid in the output SQL. This lets PG use
// native UUID indexes instead of falling back to text comparisons.
// nolint:revive
func (u astUUIDVar) EqualsSQLString(cfg *SQLGenerator, not bool, other Node) (string, error) {
switch other.UseAs().(type) {
case AstString:
// The other side is a rego string literal like
// "8c0b9bdc-a013-4b14-a49b-5747bc335708". Emit a comparison
// that casts the literal to uuid so PG can use indexes:
// column = 'val'::uuid
// instead of the text-based:
// 'val' = COALESCE(column::text, '')
s, ok := other.(AstString)
if !ok {
return "", xerrors.Errorf("expected AstString, got %T", other)
}
if s.Value == "" {
// Empty string in rego means "no value". Compare the
// column against NULL since UUID columns represent
// absent values as NULL, not empty strings.
op := "IS NULL"
if not {
op = "IS NOT NULL"
}
return fmt.Sprintf("%s %s", u.ColumnString, op), nil
}
return fmt.Sprintf("%s %s '%s'::uuid",
u.ColumnString, equalsOp(not), s.Value), nil
case astUUIDVar:
return basicSQLEquality(cfg, not, u, other), nil
default:
return "", xerrors.Errorf("unsupported equality: %T %s %T",
u, equalsOp(not), other)
}
}
// ContainedInSQL implements SupportsContainedIn so that a UUID column
// can appear in membership checks like `col = ANY(ARRAY[...])`. The
// array elements are rego strings, so we cast each to ::uuid.
func (u astUUIDVar) ContainedInSQL(_ *SQLGenerator, haystack Node) (string, error) {
arr, ok := haystack.(ASTArray)
if !ok {
return "", xerrors.Errorf("unsupported containedIn: %T in %T", u, haystack)
}
if len(arr.Value) == 0 {
return "false", nil
}
// Build ARRAY['uuid1'::uuid, 'uuid2'::uuid, ...]
values := make([]string, 0, len(arr.Value))
for _, v := range arr.Value {
s, ok := v.(AstString)
if !ok {
return "", xerrors.Errorf("expected AstString array element, got %T", v)
}
values = append(values, fmt.Sprintf("'%s'::uuid", s.Value))
}
return fmt.Sprintf("%s = ANY(ARRAY [%s])",
u.ColumnString,
strings.Join(values, ",")), nil
}
+1 -2
View File
@@ -66,7 +66,7 @@ func AuditLogs(ctx context.Context, db database.Store, query string) (database.G
}
// Prepare the count filter, which uses the same parameters as the GetAuditLogsOffsetParams.
// nolint:exhaustruct // UserID and CountCap are not obtained from the query parameters.
// nolint:exhaustruct // UserID is not obtained from the query parameters.
countFilter := database.CountAuditLogsParams{
RequestID: filter.RequestID,
ResourceID: filter.ResourceID,
@@ -123,7 +123,6 @@ func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey
}
// This MUST be kept in sync with the above
// nolint:exhaustruct // CountCap is not obtained from the query parameters.
countFilter := database.CountConnectionLogsParams{
OrganizationID: filter.OrganizationID,
WorkspaceOwner: filter.WorkspaceOwner,
+29 -150
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"slices"
"strconv"
@@ -152,12 +151,6 @@ type Server struct {
inFlightChatStaleAfter time.Duration
chatHeartbeatInterval time.Duration
// heartbeatMu guards heartbeatRegistry.
heartbeatMu sync.Mutex
// heartbeatRegistry maps chat IDs to their cancel functions
// and workspace state for the centralized heartbeat loop.
heartbeatRegistry map[uuid.UUID]*heartbeatEntry
// wakeCh is signaled by SendMessage, EditMessage, CreateChat,
// and PromoteQueued so the run loop calls processOnce
// immediately instead of waiting for the next ticker.
@@ -713,17 +706,6 @@ type chatStreamState struct {
bufferRetainedAt time.Time
}
// heartbeatEntry tracks a single chat's cancel function and workspace
// state for the centralized heartbeat loop. Instead of spawning a
// per-chat goroutine, processChat registers an entry here and the
// single heartbeatLoop goroutine handles all chats.
type heartbeatEntry struct {
cancelWithCause context.CancelCauseFunc
chatID uuid.UUID
workspaceID uuid.NullUUID
logger slog.Logger
}
// resetDropCounters zeroes the rate-limiting state for both buffer
// and subscriber drop warnings. The caller must hold s.mu.
func (s *chatStreamState) resetDropCounters() {
@@ -2438,8 +2420,8 @@ func New(cfg Config) *Server {
clock: clk,
recordingSem: make(chan struct{}, maxConcurrentRecordingUploads),
wakeCh: make(chan struct{}, 1),
heartbeatRegistry: make(map[uuid.UUID]*heartbeatEntry),
}
//nolint:gocritic // The chat processor uses a scoped chatd context.
ctx = dbauthz.AsChatd(ctx)
@@ -2479,9 +2461,6 @@ func (p *Server) start(ctx context.Context) {
// to handle chats orphaned by crashed or redeployed workers.
p.recoverStaleChats(ctx)
// Single heartbeat loop for all chats on this replica.
go p.heartbeatLoop(ctx)
acquireTicker := p.clock.NewTicker(
p.pendingChatAcquireInterval,
"chatd",
@@ -2751,97 +2730,6 @@ func (p *Server) cleanupStreamIfIdle(chatID uuid.UUID, state *chatStreamState) {
p.workspaceMCPToolsCache.Delete(chatID)
}
// registerHeartbeat enrolls a chat in the centralized batch
// heartbeat loop. Must be called after chatCtx is created.
func (p *Server) registerHeartbeat(entry *heartbeatEntry) {
p.heartbeatMu.Lock()
defer p.heartbeatMu.Unlock()
if _, exists := p.heartbeatRegistry[entry.chatID]; exists {
p.logger.Warn(context.Background(),
"duplicate heartbeat registration, skipping",
slog.F("chat_id", entry.chatID))
return
}
p.heartbeatRegistry[entry.chatID] = entry
}
// unregisterHeartbeat removes a chat from the centralized
// heartbeat loop when chat processing finishes.
func (p *Server) unregisterHeartbeat(chatID uuid.UUID) {
p.heartbeatMu.Lock()
defer p.heartbeatMu.Unlock()
delete(p.heartbeatRegistry, chatID)
}
// heartbeatLoop runs in a single goroutine, issuing one batch
// heartbeat query per interval for all registered chats.
func (p *Server) heartbeatLoop(ctx context.Context) {
ticker := p.clock.NewTicker(p.chatHeartbeatInterval, "chatd", "batch-heartbeat")
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.heartbeatTick(ctx)
}
}
}
// heartbeatTick issues a single batch UPDATE for all running chats
// owned by this worker. Chats missing from the result set are
// interrupted (stolen by another replica or already completed).
func (p *Server) heartbeatTick(ctx context.Context) {
// Snapshot the registry under the lock.
p.heartbeatMu.Lock()
snapshot := maps.Clone(p.heartbeatRegistry)
p.heartbeatMu.Unlock()
if len(snapshot) == 0 {
return
}
// Collect the IDs we believe we own.
ids := slices.Collect(maps.Keys(snapshot))
//nolint:gocritic // AsChatd provides narrowly-scoped daemon
// access for batch-updating heartbeats.
chatdCtx := dbauthz.AsChatd(ctx)
updatedIDs, err := p.db.UpdateChatHeartbeats(chatdCtx, database.UpdateChatHeartbeatsParams{
IDs: ids,
WorkerID: p.workerID,
Now: p.clock.Now(),
})
if err != nil {
p.logger.Error(ctx, "batch heartbeat failed", slog.Error(err))
return
}
// Build a set of IDs that were successfully updated.
updated := make(map[uuid.UUID]struct{}, len(updatedIDs))
for _, id := range updatedIDs {
updated[id] = struct{}{}
}
// Interrupt registered chats that were not in the result
// (stolen by another replica or already completed).
for id, entry := range snapshot {
if _, ok := updated[id]; !ok {
entry.logger.Warn(ctx, "chat not in batch heartbeat result, interrupting")
entry.cancelWithCause(chatloop.ErrInterrupted)
continue
}
// Bump workspace usage for surviving chats.
newWsID := p.trackWorkspaceUsage(ctx, entry.chatID, entry.workspaceID, entry.logger)
// Update workspace ID in the registry for next tick.
p.heartbeatMu.Lock()
if current, exists := p.heartbeatRegistry[id]; exists {
current.workspaceID = newWsID
}
p.heartbeatMu.Unlock()
}
}
func (p *Server) Subscribe(
ctx context.Context,
chatID uuid.UUID,
@@ -3316,11 +3204,7 @@ func (p *Server) publishChatPubsubEvent(chat database.Chat, kind coderdpubsub.Ch
if p.pubsub == nil {
return
}
// diffStatus is applied below. File metadata is intentionally
// omitted from pubsub events to avoid an extra DB query per
// publish. Clients must merge pubsub updates, not replace
// cached file metadata.
sdkChat := db2sdk.Chat(chat, nil, nil)
sdkChat := db2sdk.Chat(chat, nil) // we have diffStatus already converted
if diffStatus != nil {
sdkChat.DiffStatus = diffStatus
}
@@ -3687,17 +3571,33 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
}
}()
// Register with the centralized heartbeat loop instead of
// running a per-chat goroutine. The loop issues a single batch
// UPDATE for all chats on this worker and detects stolen chats
// via set-difference.
p.registerHeartbeat(&heartbeatEntry{
cancelWithCause: cancel,
chatID: chat.ID,
workspaceID: chat.WorkspaceID,
logger: logger,
})
defer p.unregisterHeartbeat(chat.ID)
// Periodically update the heartbeat so other replicas know this
// worker is still alive. The goroutine stops when chatCtx is
// canceled (either by completion or interruption).
go func() {
ticker := p.clock.NewTicker(p.chatHeartbeatInterval, "chatd", "heartbeat")
defer ticker.Stop()
for {
select {
case <-chatCtx.Done():
return
case <-ticker.C:
rows, err := p.db.UpdateChatHeartbeat(chatCtx, database.UpdateChatHeartbeatParams{
ID: chat.ID,
WorkerID: p.workerID,
})
if err != nil {
logger.Warn(chatCtx, "failed to update chat heartbeat", slog.Error(err))
continue
}
if rows == 0 {
cancel(chatloop.ErrInterrupted)
return
}
chat.WorkspaceID = p.trackWorkspaceUsage(chatCtx, chat.ID, chat.WorkspaceID, logger)
}
}
}()
// Start buffering stream events BEFORE publishing the running
// status. This closes a race where a subscriber sees
@@ -4625,27 +4525,6 @@ func (p *Server) runChat(
return uuid.Nil, xerrors.Errorf("insert chat file: %w", err)
}
// Cap enforcement and dedup are handled atomically
// in SQL. rejected > 0 = cap exceeded.
rejected, err := p.db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatSnapshot.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{row.ID},
})
switch {
case err != nil:
p.logger.Error(ctx, "failed to link file to chat",
slog.F("chat_id", chatSnapshot.ID),
slog.F("file_id", row.ID),
slog.Error(err),
)
case rejected > 0:
p.logger.Warn(ctx, "file cap reached, file not linked to chat",
slog.F("chat_id", chatSnapshot.ID),
slog.F("file_id", row.ID),
slog.F("max_file_links", codersdk.MaxChatFileIDs),
)
}
return row.ID, nil
},
}))
-129
View File
@@ -21,7 +21,6 @@ import (
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
@@ -2072,7 +2071,6 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
workerID: workerID,
chatHeartbeatInterval: time.Minute,
configCache: newChatConfigCache(ctx, db, clock),
heartbeatRegistry: make(map[uuid.UUID]*heartbeatEntry),
}
// Publish a stale "pending" notification on the control channel
@@ -2135,130 +2133,3 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
require.Equal(t, database.ChatStatusError, finalStatus,
"processChat should have reached runChat (error), not been interrupted (waiting)")
}
// TestHeartbeatTick_StolenChatIsInterrupted verifies that when the
// batch heartbeat UPDATE does not return a registered chat's ID
// (because another replica stole it or it was completed), the
// heartbeat tick cancels that chat's context with ErrInterrupted
// while leaving surviving chats untouched.
func TestHeartbeatTick_StolenChatIsInterrupted(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
workerID := uuid.New()
server := &Server{
db: db,
logger: logger,
clock: clock,
workerID: workerID,
chatHeartbeatInterval: time.Minute,
heartbeatRegistry: make(map[uuid.UUID]*heartbeatEntry),
}
// Create three chats with independent cancel functions.
chat1 := uuid.New()
chat2 := uuid.New()
chat3 := uuid.New()
_, cancel1 := context.WithCancelCause(ctx)
_, cancel2 := context.WithCancelCause(ctx)
ctx3, cancel3 := context.WithCancelCause(ctx)
server.registerHeartbeat(&heartbeatEntry{
cancelWithCause: cancel1,
chatID: chat1,
logger: logger,
})
server.registerHeartbeat(&heartbeatEntry{
cancelWithCause: cancel2,
chatID: chat2,
logger: logger,
})
server.registerHeartbeat(&heartbeatEntry{
cancelWithCause: cancel3,
chatID: chat3,
logger: logger,
})
// The batch UPDATE returns only chat1 and chat2 —
// chat3 was "stolen" by another replica.
db.EXPECT().UpdateChatHeartbeats(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, params database.UpdateChatHeartbeatsParams) ([]uuid.UUID, error) {
require.Equal(t, workerID, params.WorkerID)
require.Len(t, params.IDs, 3)
// Return only chat1 and chat2 as surviving.
return []uuid.UUID{chat1, chat2}, nil
},
)
server.heartbeatTick(ctx)
// chat3's context should be canceled with ErrInterrupted.
require.ErrorIs(t, context.Cause(ctx3), chatloop.ErrInterrupted,
"stolen chat should be interrupted")
// chat3 should have been removed from the registry by
// unregister (in production this happens via defer in
// processChat). The heartbeat tick itself does not
// unregister — it only cancels. Verify the entry is
// still present (processChat's defer would clean it up).
server.heartbeatMu.Lock()
_, chat1Exists := server.heartbeatRegistry[chat1]
_, chat2Exists := server.heartbeatRegistry[chat2]
_, chat3Exists := server.heartbeatRegistry[chat3]
server.heartbeatMu.Unlock()
require.True(t, chat1Exists, "surviving chat1 should remain registered")
require.True(t, chat2Exists, "surviving chat2 should remain registered")
require.True(t, chat3Exists,
"stolen chat3 should still be in registry (processChat defer removes it)")
}
// TestHeartbeatTick_DBErrorDoesNotInterruptChats verifies that a
// transient database failure causes the tick to log and return
// without canceling any registered chats.
func TestHeartbeatTick_DBErrorDoesNotInterruptChats(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
server := &Server{
db: db,
logger: logger,
clock: clock,
workerID: uuid.New(),
chatHeartbeatInterval: time.Minute,
heartbeatRegistry: make(map[uuid.UUID]*heartbeatEntry),
}
chatID := uuid.New()
chatCtx, cancel := context.WithCancelCause(ctx)
server.registerHeartbeat(&heartbeatEntry{
cancelWithCause: cancel,
chatID: chatID,
logger: logger,
})
// Simulate a transient DB error.
db.EXPECT().UpdateChatHeartbeats(gomock.Any(), gomock.Any()).Return(
nil, xerrors.New("connection reset"),
)
server.heartbeatTick(ctx)
// Chat should NOT be interrupted — the tick logged and
// returned early.
require.NoError(t, chatCtx.Err(),
"chat context should not be canceled on transient DB error")
}
+7 -12
View File
@@ -474,7 +474,7 @@ func TestArchiveChatInterruptsActiveProcessing(t *testing.T) {
require.Equal(t, 1, userMessages, "expected queued message to stay queued after archive")
}
func TestUpdateChatHeartbeatsRequiresOwnership(t *testing.T) {
func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -501,24 +501,19 @@ func TestUpdateChatHeartbeatsRequiresOwnership(t *testing.T) {
})
require.NoError(t, err)
// Wrong worker_id should return no IDs.
ids, err := db.UpdateChatHeartbeats(ctx, database.UpdateChatHeartbeatsParams{
IDs: []uuid.UUID{chat.ID},
rows, err := db.UpdateChatHeartbeat(ctx, database.UpdateChatHeartbeatParams{
ID: chat.ID,
WorkerID: uuid.New(),
Now: time.Now(),
})
require.NoError(t, err)
require.Empty(t, ids)
require.Equal(t, int64(0), rows)
// Correct worker_id should return the chat's ID.
ids, err = db.UpdateChatHeartbeats(ctx, database.UpdateChatHeartbeatsParams{
IDs: []uuid.UUID{chat.ID},
rows, err = db.UpdateChatHeartbeat(ctx, database.UpdateChatHeartbeatParams{
ID: chat.ID,
WorkerID: workerID,
Now: time.Now(),
})
require.NoError(t, err)
require.Len(t, ids, 1)
require.Equal(t, chat.ID, ids[0])
require.Equal(t, int64(1), rows)
}
func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
+2 -2
View File
@@ -70,8 +70,8 @@ func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
require.Equal(t, 1, persistStepCalls)
require.True(t, persistedStep.ContextLimit.Valid)
require.Equal(t, int64(4096), persistedStep.ContextLimit.Int64)
require.GreaterOrEqual(t, persistedStep.Runtime, time.Duration(0),
"step runtime should be non-negative")
require.Greater(t, persistedStep.Runtime, time.Duration(0),
"step runtime should be positive")
require.NotEmpty(t, capturedCall.Prompt)
require.False(t, containsPromptSentinel(capturedCall.Prompt))
+5 -34
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
@@ -50,11 +49,10 @@ const connectTimeout = 10 * time.Second
const toolCallTimeout = 60 * time.Second
// ConnectAll connects to all configured MCP servers, discovers
// their tools, and returns them as fantasy.AgentTool values.
// Tools are sorted by their prefixed name so callers
// receive a deterministic order. It skips servers that fail to
// connect and logs warnings. The returned cleanup function
// must be called to close all connections.
// their tools, and returns them as fantasy.AgentTool values. It
// skips servers that fail to connect and logs warnings. The
// returned cleanup function must be called to close all
// connections.
func ConnectAll(
ctx context.Context,
logger slog.Logger,
@@ -110,9 +108,7 @@ func ConnectAll(
}
mu.Lock()
if mcpClient != nil {
clients = append(clients, mcpClient)
}
clients = append(clients, mcpClient)
tools = append(tools, serverTools...)
mu.Unlock()
return nil
@@ -123,31 +119,6 @@ func ConnectAll(
// discarded.
_ = eg.Wait()
// Sort tools by prefixed name for deterministic ordering
// regardless of goroutine completion order. Ties, possible
// when the __ separator produces ambiguous prefixed names,
// are broken by config ID. Stable prompt construction
// depends on consistent tool ordering.
slices.SortFunc(tools, func(a, b fantasy.AgentTool) int {
// All tools in this slice are mcpToolWrapper values
// created by connectOne above, so these checked
// assertions should always succeed. The config ID
// tiebreaker resolves the __ separator ambiguity
// documented at the top of this file.
aTool, ok := a.(MCPToolIdentifier)
if !ok {
panic(fmt.Sprintf("unexpected tool type %T", a))
}
bTool, ok := b.(MCPToolIdentifier)
if !ok {
panic(fmt.Sprintf("unexpected tool type %T", b))
}
return cmp.Or(
cmp.Compare(a.Info().Name, b.Info().Name),
cmp.Compare(aTool.MCPServerConfigID().String(), bTool.MCPServerConfigID().String()),
)
})
return tools, cleanup
}
-126
View File
@@ -63,17 +63,6 @@ func greetTool() mcpserver.ServerTool {
}
}
// makeTool returns a ServerTool with the given name and a
// no-op handler that always returns "ok".
func makeTool(name string) mcpserver.ServerTool {
return mcpserver.ServerTool{
Tool: mcp.NewTool(name),
Handler: func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText("ok"), nil
},
}
}
// makeConfig builds a database.MCPServerConfig suitable for tests.
func makeConfig(slug, url string) database.MCPServerConfig {
return database.MCPServerConfig{
@@ -209,121 +198,6 @@ func TestConnectAll_MultipleServers(t *testing.T) {
assert.Contains(t, names, "beta__greet")
}
func TestConnectAll_NoToolsAfterFiltering(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ts := newTestMCPServer(t, echoTool())
cfg := makeConfig("filtered", ts.URL)
cfg.ToolAllowList = []string{"greet"}
tools, cleanup := mcpclient.ConnectAll(
ctx,
logger,
[]database.MCPServerConfig{cfg},
nil,
)
require.Empty(t, tools)
assert.NotPanics(t, cleanup)
}
func TestConnectAll_DeterministicOrder(t *testing.T) {
t.Parallel()
t.Run("AcrossServers", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ts1 := newTestMCPServer(t, makeTool("zebra"))
ts2 := newTestMCPServer(t, makeTool("alpha"))
ts3 := newTestMCPServer(t, makeTool("middle"))
tools, cleanup := mcpclient.ConnectAll(
ctx,
logger,
[]database.MCPServerConfig{
makeConfig("srv3", ts3.URL),
makeConfig("srv1", ts1.URL),
makeConfig("srv2", ts2.URL),
},
nil,
)
t.Cleanup(cleanup)
require.Len(t, tools, 3)
// Sorted by full prefixed name (slug__tool), so slug
// order determines the sequence, not the tool name.
assert.Equal(t,
[]string{"srv1__zebra", "srv2__alpha", "srv3__middle"},
toolNames(tools),
)
})
t.Run("WithMultiToolServer", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
multi := newTestMCPServer(t, makeTool("zeta"), makeTool("beta"))
other := newTestMCPServer(t, makeTool("gamma"))
tools, cleanup := mcpclient.ConnectAll(
ctx,
logger,
[]database.MCPServerConfig{
makeConfig("zzz", multi.URL),
makeConfig("aaa", other.URL),
},
nil,
)
t.Cleanup(cleanup)
require.Len(t, tools, 3)
assert.Equal(t,
[]string{"aaa__gamma", "zzz__beta", "zzz__zeta"},
toolNames(tools),
)
})
t.Run("TiebreakByConfigID", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ts1 := newTestMCPServer(t, makeTool("b__z"))
ts2 := newTestMCPServer(t, makeTool("z"))
// Use fixed UUIDs so the tiebreaker order is
// predictable. Both servers produce the same prefixed
// name, a__b__z, due to the __ separator ambiguity.
cfg1 := makeConfig("a", ts1.URL)
cfg1.ID = uuid.MustParse("00000000-0000-0000-0000-000000000002")
cfg2 := makeConfig("a__b", ts2.URL)
cfg2.ID = uuid.MustParse("00000000-0000-0000-0000-000000000001")
tools, cleanup := mcpclient.ConnectAll(
ctx,
logger,
[]database.MCPServerConfig{cfg1, cfg2},
nil,
)
t.Cleanup(cleanup)
require.Len(t, tools, 2)
assert.Equal(t, []string{"a__b__z", "a__b__z"}, toolNames(tools))
id0 := tools[0].(mcpclient.MCPToolIdentifier).MCPServerConfigID()
id1 := tools[1].(mcpclient.MCPToolIdentifier).MCPServerConfigID()
assert.Equal(t, cfg2.ID, id0, "lower config ID should sort first")
assert.Equal(t, cfg1.ID, id1, "higher config ID should sort second")
})
}
func TestConnectAll_AuthHeaders(t *testing.T) {
t.Parallel()
ctx := context.Background()
+5 -10
View File
@@ -1055,17 +1055,12 @@ func TestAwaitSubagentCompletion(t *testing.T) {
require.NoError(t, err)
defer cancelProbe()
// Insert the message BEFORE transitioning to Waiting.
// Stale PG LISTEN/NOTIFY notifications from the
// processor's earlier run can still be buffered in the
// pgListener after drainInflight returns. If such a
// notification is dispatched between setChatStatus and
// insertAssistantMessage, checkSubagentCompletion would
// see done=true (Waiting) with an empty report. By
// inserting the message first, the report is guaranteed
// to be committed before the status makes it visible.
insertAssistantMessage(ctx, t, db, child.ID, model.ID, "pubsub result")
// 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")
require.EventuallyWithT(t, func(c *assert.CollectT) {
chat, report, done, err := server.checkSubagentCompletion(ctx, child.ID)
require.NoError(c, err)
-1
View File
@@ -212,7 +212,6 @@ type AuditLogsRequest struct {
type AuditLogResponse struct {
AuditLogs []AuditLog `json:"audit_logs"`
Count int64 `json:"count"`
CountCap int64 `json:"count_cap"`
}
type CreateTestAuditLogRequest struct {
+23 -51
View File
@@ -26,12 +26,6 @@ import (
// threshold settings.
const ChatCompactionThresholdKeyPrefix = "chat_compaction_threshold_pct:"
// MaxChatFileIDs is the maximum number of file IDs that can be
// associated with a single chat. This limit prevents unbounded
// growth in the chat_file_links table. It is easier to raise
// this limit than to lower it.
const MaxChatFileIDs = 20
// CompactionThresholdKey returns the user-config key for a specific
// model configuration's compaction threshold.
func CompactionThresholdKey(modelConfigID uuid.UUID) string {
@@ -52,25 +46,24 @@ const (
// Chat represents a chat session with an AI agent.
type Chat struct {
ID uuid.UUID `json:"id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"`
AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"`
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"`
Title string `json:"title"`
Status ChatStatus `json:"status"`
LastError *string `json:"last_error"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
Archived bool `json:"archived"`
PinOrder int32 `json:"pin_order"`
MCPServerIDs []uuid.UUID `json:"mcp_server_ids" format:"uuid"`
Labels map[string]string `json:"labels"`
Files []ChatFileMetadata `json:"files,omitempty"`
ID uuid.UUID `json:"id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"`
AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"`
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"`
Title string `json:"title"`
Status ChatStatus `json:"status"`
LastError *string `json:"last_error"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
Archived bool `json:"archived"`
PinOrder int32 `json:"pin_order"`
MCPServerIDs []uuid.UUID `json:"mcp_server_ids" format:"uuid"`
Labels map[string]string `json:"labels"`
// HasUnread is true when assistant messages exist beyond
// the owner's read cursor, which updates on stream
// connect and disconnect.
@@ -80,18 +73,6 @@ type Chat struct {
// is updated only when context changes — first workspace
// attach or agent change.
LastInjectedContext []ChatMessagePart `json:"last_injected_context,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// ChatFileMetadata contains lightweight metadata about a file
// associated with a chat, excluding the file content itself.
type ChatFileMetadata struct {
ID uuid.UUID `json:"id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
Name string `json:"name"`
MimeType string `json:"mime_type"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
}
// ChatMessage represents a single message in a chat.
@@ -421,15 +402,6 @@ type CreateChatMessageResponse struct {
Message *ChatMessage `json:"message,omitempty"`
QueuedMessage *ChatQueuedMessage `json:"queued_message,omitempty"`
Queued bool `json:"queued"`
Warnings []string `json:"warnings,omitempty"`
}
// EditChatMessageResponse is the response from editing a message in a chat.
// Edits are always synchronous (no queueing), so the message is returned
// directly.
type EditChatMessageResponse struct {
Message ChatMessage `json:"message"`
Warnings []string `json:"warnings,omitempty"`
}
// UploadChatFileResponse is the response from uploading a chat file.
@@ -1984,7 +1956,7 @@ func (c *ExperimentalClient) EditChatMessage(
chatID uuid.UUID,
messageID int64,
req EditChatMessageRequest,
) (EditChatMessageResponse, error) {
) (ChatMessage, error) {
res, err := c.Request(
ctx,
http.MethodPatch,
@@ -1992,14 +1964,14 @@ func (c *ExperimentalClient) EditChatMessage(
req,
)
if err != nil {
return EditChatMessageResponse{}, err
return ChatMessage{}, err
}
if res.StatusCode != http.StatusOK {
return EditChatMessageResponse{}, readBodyAsChatUsageLimitError(res)
return ChatMessage{}, readBodyAsChatUsageLimitError(res)
}
defer res.Body.Close()
var resp EditChatMessageResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
var message ChatMessage
return message, json.NewDecoder(res.Body).Decode(&message)
}
// InterruptChat cancels an in-flight chat run and leaves it waiting.
-1
View File
@@ -96,7 +96,6 @@ type ConnectionLogsRequest struct {
type ConnectionLogResponse struct {
ConnectionLogs []ConnectionLog `json:"connection_logs"`
Count int64 `json:"count"`
CountCap int64 `json:"count_cap"`
}
func (c *Client) ConnectionLogs(ctx context.Context, req ConnectionLogsRequest) (ConnectionLogResponse, error) {
+2 -24
View File
@@ -134,12 +134,6 @@ edited message onward, truncating any messages that followed it.
|-----------|-------------------|----------|----------------------------------|
| `content` | `ChatInputPart[]` | yes | The replacement message content. |
The response is an `EditChatMessageResponse` with the edited `message`
and an optional `warnings` array. When file references in the edited
content cannot be linked (e.g. the per-chat file cap is reached), the
edit still succeeds and the `warnings` array describes which files
were not linked.
### Stream updates
`GET /api/experimental/chats/{chat}/stream`
@@ -207,9 +201,7 @@ Each event is a JSON object with `kind` and `chat` fields:
`GET /api/experimental/chats`
Returns all chats owned by the authenticated user. The `files` field is
populated on `POST /chats` and `GET /chats/{id}`. Other endpoints that
return a `Chat` object omit it.
Returns all chats owned by the authenticated user.
| Query parameter | Type | Required | Description |
|-----------------|----------|----------|------------------------------------------------------------------|
@@ -220,17 +212,7 @@ return a `Chat` object omit it.
`GET /api/experimental/chats/{chat}`
Returns the `Chat` object (metadata only, no messages). The response
includes a `files` field (`ChatFileMetadata[]`) containing metadata for
files that have been successfully linked to the chat. File linking is
best-effort; if linking fails, the file remains in message content but
will be absent from this field.
When file linking is skipped (e.g. the per-chat file cap is reached),
`POST /chats` includes a `warnings` array on the `Chat` response and
`POST /chats/{chat}/messages` includes a `warnings` array on the
`CreateChatMessageResponse`. The `warnings` field is `omitempty` and
absent when all files are linked successfully.
Returns the `Chat` object (metadata only, no messages).
### Get chat messages
@@ -313,10 +295,6 @@ file, use `GET /api/experimental/chats/files/{file}`.
Supported formats: PNG, JPEG, GIF, WebP (up to 10 MB). The server
validates actual file content regardless of the declared `Content-Type`.
Files referenced in messages are automatically linked to the chat and
appear in the `files` field on subsequent
`GET /api/experimental/chats/{chat}` responses.
## Chat statuses
| Status | Meaning |
-6
View File
@@ -1299,12 +1299,6 @@
"description": "Custom claims/scopes with Okta for group/role sync",
"path": "./tutorials/configuring-okta.md"
},
{
"title": "Persistent Shared Workspaces",
"description": "Set up long-lived shared workspaces with service accounts and workspace sharing",
"path": "./tutorials/persistent-shared-workspaces.md",
"state": ["premium"]
},
{
"title": "Google to AWS Federation",
"description": "Federating a Google Cloud service account to AWS",
+1 -2
View File
@@ -90,8 +90,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?limit=0 \
"user_agent": "string"
}
],
"count": 0,
"count_cap": 0
"count": 0
}
```
+1 -2
View File
@@ -291,8 +291,7 @@ curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \
"workspace_owner_username": "string"
}
],
"count": 0,
"count_cap": 0
"count": 0
}
```
+2 -6
View File
@@ -1740,8 +1740,7 @@
"user_agent": "string"
}
],
"count": 0,
"count_cap": 0
"count": 0
}
```
@@ -1751,7 +1750,6 @@
|--------------|-------------------------------------------------|----------|--------------|-------------|
| `audit_logs` | array of [codersdk.AuditLog](#codersdkauditlog) | false | | |
| `count` | integer | false | | |
| `count_cap` | integer | false | | |
## codersdk.AuthMethod
@@ -2175,8 +2173,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"workspace_owner_username": "string"
}
],
"count": 0,
"count_cap": 0
"count": 0
}
```
@@ -2186,7 +2183,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|-------------------|-----------------------------------------------------------|----------|--------------|-------------|
| `connection_logs` | array of [codersdk.ConnectionLog](#codersdkconnectionlog) | false | | |
| `count` | integer | false | | |
| `count_cap` | integer | false | | |
## codersdk.ConnectionLogSSHInfo
@@ -1,258 +0,0 @@
# Persistent Shared Workspaces with Service Accounts
> [!NOTE]
> This guide requires a
> [Premium license](https://coder.com/pricing#compare-plans) because service
> accounts are a Premium feature. For more details,
> [contact your account team](https://coder.com/contact).
This guide walks through setting up a long-lived workspace that is owned by a
service account and shared with a rotating set of users. Because no single
person owns the workspace, it persists across team changes and every user
authenticates as themselves.
This pattern is useful for any scenario where a workspace outlives the people
who use it:
- **On-call rotations** — Engineers share a workspace pre-loaded with runbooks,
dashboards, and monitoring tools. Access rotates with the shift schedule.
- **Shared staging or QA** — A team workspace hosts a persistent staging
environment. Testers and reviewers are added and removed as sprints change.
- **Pair programming** — A service-account-owned workspace gives two or more
developers a shared environment without either one owning (and accidentally
deleting) it.
- **Contractor onboarding** — An external team gets scoped access to a workspace
for the duration of an engagement, then access is revoked.
The steps below use an **on-call SRE workspace** as a running example, but the
same commands apply to any of the scenarios above. Substitute the usernames,
group names, and template to match your use case.
## Prerequisites
- A running Coder deployment (v2.32+) with workspace sharing enabled. Sharing
is on by default for OSS; Premium deployments may require
[admin configuration](../user-guides/shared-workspaces.md#policies).
- The [Coder CLI](../install/index.md) installed and authenticated.
- An account with the `Owner` or `User Admin` role.
- [OIDC authentication](../admin/users/oidc-auth/index.md) configured so
shared users log in with their corporate SSO identity. Configure
[refresh tokens](../admin/users/oidc-auth/refresh-tokens.md) to prevent
session timeouts during long work sessions.
- A [wildcard access URL](../admin/networking/wildcard-access-url.md) configured
(e.g. `*.coder.example.com`) so that shared users can access workspace apps
without a 404.
- (Recommended) [IdP Group Sync](../admin/users/idp-sync.md#group-sync)
configured if your identity provider manages group membership for the teams
that will share the workspace.
## 1. Create a service account
Create a dedicated service account that will own the shared workspace. Service
accounts are non-human accounts intended for automation and shared ownership.
Because no individual user owns the workspace, there are no personal
credentials to expose and the shared environment is not affected when any user
leaves the team or the organization.
```shell
# On-call example — substitute a name that fits your use case
coder users create \
--username oncall-sre \
--service-account
```
## 2. Generate an API token for the service account
Generate a long-lived API token so you can create and manage workspaces on
behalf of the service account:
```shell
coder tokens create \
--user oncall-sre \
--name oncall-automation \
--lifetime 8760h
```
Store this token securely (e.g. in a secrets manager like Vault or AWS Secrets
Manager).
> [!IMPORTANT]
> Never distribute this token to end users. The token is for workspace
> administration only. Shared users authenticate as themselves and reach the
> workspace through sharing.
## 3. Create the workspace
Authenticate as the service account and create the workspace:
```shell
export CODER_SESSION_TOKEN="<token-from-step-2>"
coder create oncall-sre/oncall-workspace \
--template your-oncall-template \
--use-parameter-defaults \
--yes
```
> [!TIP]
> Design a dedicated template for the workspace with the tools your team
> needs pre-installed (e.g. monitoring dashboards for on-call, test runners
> for QA). Set `subdomain = true` on workspace apps so that shared users can
> access web-based tools without a 404. See
> [Accessing workspace apps in shared workspaces](../user-guides/shared-workspaces.md#accessing-workspace-apps-in-shared-workspaces).
## 4. Share the workspace
Use `coder sharing share` to grant access to users who need the workspace:
```shell
coder sharing share oncall-sre/oncall-workspace --user alice
```
This gives `alice` the default `use` role, which allows connection via SSH and
workspace apps, starting and stopping the workspace, and viewing logs and stats.
To grant `admin` permissions (which includes all `use` permissions as well as renaming, updating, and inviting
others to join with the `use` role):
```shell
coder sharing share oncall-sre/oncall-workspace --user alice:admin
```
To share with multiple users at once:
```shell
coder sharing share oncall-sre/oncall-workspace --user alice:admin,bob
```
To share with an entire Coder group:
```shell
coder sharing share oncall-sre/oncall-workspace --group sre-oncall
```
> [!NOTE]
> Groups can be synced from your identity provider using
> [IdP Sync](../admin/users/idp-sync.md#group-sync). If your IdP already
> manages team membership, sharing with a group is the simplest approach.
## 5. Rotate access
When team membership changes, remove outgoing users and add incoming ones:
```shell
# Remove outgoing user
coder sharing remove oncall-sre/oncall-workspace --user alice
# Add incoming user
coder sharing share oncall-sre/oncall-workspace --user carol
```
> [!IMPORTANT]
> The workspace must be restarted for user removal to take effect.
Verify current sharing status at any time:
```shell
coder sharing status oncall-sre/oncall-workspace
```
## 6. Automate access changes (optional)
For use cases with frequent rotation (such as on-call shifts), you can integrate
the share/remove commands into external tooling like PagerDuty, Opsgenie, or a
cron job.
### Rotation script
```shell
#!/bin/bash
# rotate-access.sh
# Usage: ./rotate-access.sh <outgoing-user> <incoming-user>
WORKSPACE="oncall-sre/oncall-workspace"
OUTGOING="$1"
INCOMING="$2"
if [ -n "$OUTGOING" ]; then
echo "Removing access for $OUTGOING..."
coder sharing remove "$WORKSPACE" --user "$OUTGOING"
fi
echo "Granting access to $INCOMING..."
coder sharing share "$WORKSPACE" --user "$INCOMING"
echo "Restarting workspace to apply changes..."
coder restart "$WORKSPACE" --yes
echo "Current sharing status:"
coder sharing status "$WORKSPACE"
```
### Group-based rotation with IdP Sync
If your identity provider manages group membership (e.g. an `sre-oncall` group
in Okta or Azure AD), you can skip manual share/remove commands entirely:
1. Configure [Group Sync](../admin/users/idp-sync.md#group-sync) to
synchronize the group from your IdP to Coder.
1. Share the workspace with the group once:
```shell
coder sharing share oncall-sre/oncall-workspace --group sre-oncall
```
1. When your IdP rotates group membership, Coder group membership updates on
next login. All current members have access; removed members lose access
after a workspace restart.
## Finding shared workspaces
Shared users can find workspaces shared with them:
```shell
# List all workspaces shared with you
coder list --search shared:true
# List workspaces shared with a specific user
coder list --search shared_with_user:alice
# List workspaces shared with a specific group
coder list --search shared_with_group:sre-oncall
```
## Troubleshooting
### Shared user sees 404 on workspace apps
Workspace apps using path-based routing block non-owners by default. Configure a
[wildcard access URL](../admin/networking/wildcard-access-url.md) and set
`subdomain = true` on the workspace app in your template.
### Removed user still has access
Access removal requires a workspace restart. Run
`coder restart <workspace>` after removing a user or group.
### Group sync not updating membership
Group membership changes in your IdP are not reflected until the user logs out
and back in. Group sync runs at login time, not on a polling schedule. Check the
Coder server logs with
`CODER_LOG_FILTER=".*userauth.*|.*groups returned.*"` for details. See
[Troubleshooting group sync](../admin/users/idp-sync.md#troubleshooting-grouproleorganization-sync)
for more information.
## Next steps
- [Shared Workspaces](../user-guides/shared-workspaces.md) — full reference
for workspace sharing features and UI
- [IdP Sync](../admin/users/idp-sync.md) — group, role, and organization
sync configuration
- [Configuring Okta](./configuring-okta.md) — Okta-specific OIDC setup with
custom claims and scopes
- [Security Best Practices](./best-practices/security-best-practices.md) —
deployment-wide security hardening
- [Sessions and Tokens](../admin/users/sessions-tokens.md) — API token
management and scoping
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
}
docker = {
source = "kreuzwerker/docker"
version = "~> 4.0"
version = "~> 3.0"
}
envbuilder = {
source = "coder/envbuilder"
+3 -3
View File
@@ -1,5 +1,5 @@
# 1.93.1
FROM rust:slim@sha256:a08d20a404f947ed358dfb63d1ee7e0b88ecad3c45ba9682ccbf2cb09c98acca AS rust-utils
FROM rust:slim@sha256:1d0000a49fb62f4fde24455f49d59c6c088af46202d65d8f455b722f7263e8f8 AS rust-utils
# Install rust helper programs
ENV CARGO_INSTALL_ROOT=/tmp/
# Use more reliable mirrors for Debian packages
@@ -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:eb29ed27b0821dca09c2e28b39135e185fc1302036427d5f4d70a41ce8fd7659 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:eb29ed27b0821dca09c2e28b39135e185fc1302036427d5f4d70a41ce8fd7659
FROM ubuntu:jammy@sha256:5e5b128eb4ff35ee52687c20d081dcc15b8cb55e113247683f435224fc58b956
SHELL ["/bin/bash", "-c"]
+2 -2
View File
@@ -6,7 +6,7 @@ terraform {
}
docker = {
source = "kreuzwerker/docker"
version = "~> 4.0"
version = "~> 3.6"
}
}
}
@@ -922,7 +922,7 @@ resource "coder_script" "boundary_config_setup" {
module "claude-code" {
count = data.coder_task.me.enabled ? data.coder_workspace.me.start_count : 0
source = "dev.registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.8.2"
enable_boundary = true
agent_id = coder_agent.dev.id
workdir = local.repo_dir
-6
View File
@@ -16,9 +16,6 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// NOTE: See the auditLogCountCap note.
const connectionLogCountCap = 2000
// @Summary Get connection logs
// @ID get-connection-logs
// @Security CoderSessionToken
@@ -52,7 +49,6 @@ func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) {
// #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range
filter.LimitOpt = int32(page.Limit)
countFilter.CountCap = connectionLogCountCap
count, err := api.Database.CountConnectionLogs(ctx, countFilter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
@@ -67,7 +63,6 @@ func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{
ConnectionLogs: []codersdk.ConnectionLog{},
Count: 0,
CountCap: connectionLogCountCap,
})
return
}
@@ -85,7 +80,6 @@ func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{
ConnectionLogs: convertConnectionLogs(dblogs),
Count: count,
CountCap: connectionLogCountCap,
})
}
+1 -1
View File
@@ -33,7 +33,7 @@ data "coder_task" "me" {}
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.8.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/projects"
order = 999
+7 -7
View File
@@ -91,7 +91,7 @@ require (
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/adrg/xdg v0.5.0
github.com/ammario/tlru v0.4.0
github.com/andybalholm/brotli v1.2.1
github.com/andybalholm/brotli v1.2.0
github.com/aquasecurity/trivy-iac v0.8.0
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/awalterschulze/gographviz v2.0.3+incompatible
@@ -136,11 +136,11 @@ require (
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.15.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-jose/go-jose/v4 v4.1.3
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.160.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
@@ -162,7 +162,7 @@ require (
github.com/justinas/nosurf v1.2.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
github.com/klauspost/compress v1.18.5
github.com/klauspost/compress v1.18.4
github.com/lib/pq v1.10.9
github.com/mattn/go-isatty v0.0.20
github.com/mitchellh/go-wordwrap v1.0.1
@@ -193,7 +193,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/u-root/u-root v0.14.0
github.com/unrolled/secure v1.17.0
github.com/valyala/fasthttp v1.70.0
github.com/valyala/fasthttp v1.69.0
github.com/wagslane/go-password-validator v0.3.0
github.com/zclconf/go-cty-yaml v1.2.0
go.mozilla.org/pkcs7 v0.9.0
@@ -218,7 +218,7 @@ require (
golang.org/x/text v0.35.0
golang.org/x/tools v0.43.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
google.golang.org/api v0.274.0
google.golang.org/api v0.273.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0
@@ -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.8.2 // 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
+20 -20
View File
@@ -128,8 +128,8 @@ github.com/ammario/tlru v0.4.0 h1:sJ80I0swN3KOX2YxC6w8FbCqpQucWdbb+J36C05FPuU=
github.com/ammario/tlru v0.4.0/go.mod h1:aYzRFu0XLo4KavE9W8Lx7tzjkX+pAApz+NgcKYIFUBQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU=
@@ -518,8 +518,8 @@ github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -611,12 +611,12 @@ 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.160.0 h1:WmmygLg2ahijM4w2VHFn/DdBR+OpJ9H9pH3d8OApNDY=
github.com/gohugoio/hugo v0.160.0/go.mod h1:+VA5jOO3iGELh+6cig098PT2Cd9iNhwUPRqCUe3Ce7w=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
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=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.4.0/go.mod h1:lK1CjqrueCd3OBnsLLQJGrQ+uodWfT9M9Cq2zfDWJCE=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -794,8 +794,8 @@ github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNq
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
@@ -1188,8 +1188,8 @@ github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
@@ -1252,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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/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=
@@ -1375,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.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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=
@@ -1514,8 +1514,8 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA=
google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
+1 -1
View File
@@ -9,7 +9,7 @@
"storybook": "pnpm run -C site/ storybook"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@biomejs/biome": "2.2.0",
"markdown-table-formatter": "^1.6.1",
"markdownlint-cli2": "^0.16.0",
"quicktype": "^23.0.0"
+37 -37
View File
@@ -12,8 +12,8 @@ importers:
.:
devDependencies:
'@biomejs/biome':
specifier: 2.4.10
version: 2.4.10
specifier: 2.2.0
version: 2.2.0
markdown-table-formatter:
specifier: ^1.6.1
version: 1.6.1
@@ -26,55 +26,55 @@ importers:
packages:
'@biomejs/biome@2.4.10':
resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==}
'@biomejs/biome@2.2.0':
resolution: {integrity: sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.4.10':
resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==}
'@biomejs/cli-darwin-arm64@2.2.0':
resolution: {integrity: sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.4.10':
resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==}
'@biomejs/cli-darwin-x64@2.2.0':
resolution: {integrity: sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.4.10':
resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==}
'@biomejs/cli-linux-arm64-musl@2.2.0':
resolution: {integrity: sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.4.10':
resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==}
'@biomejs/cli-linux-arm64@2.2.0':
resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.4.10':
resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==}
'@biomejs/cli-linux-x64-musl@2.2.0':
resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.4.10':
resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==}
'@biomejs/cli-linux-x64@2.2.0':
resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.4.10':
resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==}
'@biomejs/cli-win32-arm64@2.2.0':
resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.4.10':
resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==}
'@biomejs/cli-win32-x64@2.2.0':
resolution: {integrity: sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -778,39 +778,39 @@ packages:
snapshots:
'@biomejs/biome@2.4.10':
'@biomejs/biome@2.2.0':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.10
'@biomejs/cli-darwin-x64': 2.4.10
'@biomejs/cli-linux-arm64': 2.4.10
'@biomejs/cli-linux-arm64-musl': 2.4.10
'@biomejs/cli-linux-x64': 2.4.10
'@biomejs/cli-linux-x64-musl': 2.4.10
'@biomejs/cli-win32-arm64': 2.4.10
'@biomejs/cli-win32-x64': 2.4.10
'@biomejs/cli-darwin-arm64': 2.2.0
'@biomejs/cli-darwin-x64': 2.2.0
'@biomejs/cli-linux-arm64': 2.2.0
'@biomejs/cli-linux-arm64-musl': 2.2.0
'@biomejs/cli-linux-x64': 2.2.0
'@biomejs/cli-linux-x64-musl': 2.2.0
'@biomejs/cli-win32-arm64': 2.2.0
'@biomejs/cli-win32-x64': 2.2.0
'@biomejs/cli-darwin-arm64@2.4.10':
'@biomejs/cli-darwin-arm64@2.2.0':
optional: true
'@biomejs/cli-darwin-x64@2.4.10':
'@biomejs/cli-darwin-x64@2.2.0':
optional: true
'@biomejs/cli-linux-arm64-musl@2.4.10':
'@biomejs/cli-linux-arm64-musl@2.2.0':
optional: true
'@biomejs/cli-linux-arm64@2.4.10':
'@biomejs/cli-linux-arm64@2.2.0':
optional: true
'@biomejs/cli-linux-x64-musl@2.4.10':
'@biomejs/cli-linux-x64-musl@2.2.0':
optional: true
'@biomejs/cli-linux-x64@2.4.10':
'@biomejs/cli-linux-x64@2.2.0':
optional: true
'@biomejs/cli-win32-arm64@2.4.10':
'@biomejs/cli-win32-arm64@2.2.0':
optional: true
'@biomejs/cli-win32-x64@2.4.10':
'@biomejs/cli-win32-x64@2.2.0':
optional: true
'@cspotcode/source-map-support@0.8.1':
+150 -152
View File
@@ -12,164 +12,162 @@ import {
} from "../helpers";
import { beforeCoderTest, resetExternalAuthKey } from "../hooks";
test.describe
.skip("externalAuth", () => {
test.beforeAll(async ({ baseURL }) => {
const srv = await createServer(gitAuth.webPort);
test.describe.skip("externalAuth", () => {
test.beforeAll(async ({ baseURL }) => {
const srv = await createServer(gitAuth.webPort);
// The GitHub validate endpoint returns the currently authenticated user!
srv.use(gitAuth.validatePath, (_req, res) => {
res.write(JSON.stringify(ghUser));
res.end();
});
srv.use(gitAuth.tokenPath, (_req, res) => {
const r = (Math.random() + 1).toString(36).substring(7);
res.write(JSON.stringify({ access_token: r }));
res.end();
});
srv.use(gitAuth.authPath, (req, res) => {
res.redirect(
`${baseURL}/external-auth/${gitAuth.webProvider}/callback?code=1234&state=${req.query.state}`,
);
});
// The GitHub validate endpoint returns the currently authenticated user!
srv.use(gitAuth.validatePath, (_req, res) => {
res.write(JSON.stringify(ghUser));
res.end();
});
test.beforeEach(async ({ context, page }) => {
beforeCoderTest(page);
await login(page);
await resetExternalAuthKey(context);
srv.use(gitAuth.tokenPath, (_req, res) => {
const r = (Math.random() + 1).toString(36).substring(7);
res.write(JSON.stringify({ access_token: r }));
res.end();
});
// Ensures that a Git auth provider with the device flow functions and completes!
test("external auth device", async ({ page }) => {
const device: ExternalAuthDevice = {
device_code: "1234",
user_code: "1234-5678",
expires_in: 900,
interval: 1,
verification_uri: "",
};
// Start a server to mock the GitHub API.
const srv = await createServer(gitAuth.devicePort);
srv.use(gitAuth.validatePath, (_req, res) => {
res.write(JSON.stringify(ghUser));
res.end();
});
srv.use(gitAuth.codePath, (_req, res) => {
res.write(JSON.stringify(device));
res.end();
});
srv.use(gitAuth.installationsPath, (_req, res) => {
res.write(JSON.stringify(ghInstall));
res.end();
});
const token = {
access_token: "",
error: "authorization_pending",
error_description: "",
};
// First we send a result from the API that the token hasn't been
// authorized yet to ensure the UI reacts properly.
const sentPending = new Awaiter();
srv.use(gitAuth.tokenPath, (_req, res) => {
res.write(JSON.stringify(token));
res.end();
sentPending.done();
});
await page.goto(`/external-auth/${gitAuth.deviceProvider}`, {
waitUntil: "domcontentloaded",
});
await page.getByText(device.user_code).isVisible();
await sentPending.wait();
// Update the token to be valid and ensure the UI updates!
token.error = "";
token.access_token = "hello-world";
await page.waitForSelector("text=1 organization authorized");
});
test("external auth web", async ({ page }) => {
await page.goto(`/external-auth/${gitAuth.webProvider}`, {
waitUntil: "domcontentloaded",
});
// This endpoint doesn't have the installations URL set intentionally!
await page.waitForSelector("text=You've authenticated with GitHub!");
});
test("successful external auth from workspace", async ({ page }) => {
const templateName = await createTemplate(
page,
echoResponsesWithExternalAuth([
{ id: gitAuth.webProvider, optional: false },
]),
srv.use(gitAuth.authPath, (req, res) => {
res.redirect(
`${baseURL}/external-auth/${gitAuth.webProvider}/callback?code=1234&state=${req.query.state}`,
);
await createWorkspace(page, templateName, { useExternalAuth: true });
});
});
const ghUser: Endpoints["GET /user"]["response"]["data"] = {
login: "kylecarbs",
id: 7122116,
node_id: "MDQ6VXNlcjcxMjIxMTY=",
avatar_url: "https://avatars.githubusercontent.com/u/7122116?v=4",
gravatar_id: "",
url: "https://api.github.com/users/kylecarbs",
html_url: "https://github.com/kylecarbs",
followers_url: "https://api.github.com/users/kylecarbs/followers",
following_url:
"https://api.github.com/users/kylecarbs/following{/other_user}",
gists_url: "https://api.github.com/users/kylecarbs/gists{/gist_id}",
starred_url:
"https://api.github.com/users/kylecarbs/starred{/owner}{/repo}",
subscriptions_url: "https://api.github.com/users/kylecarbs/subscriptions",
organizations_url: "https://api.github.com/users/kylecarbs/orgs",
repos_url: "https://api.github.com/users/kylecarbs/repos",
events_url: "https://api.github.com/users/kylecarbs/events{/privacy}",
received_events_url:
"https://api.github.com/users/kylecarbs/received_events",
type: "User",
site_admin: false,
name: "Kyle Carberry",
company: "@coder",
blog: "https://carberry.com",
location: "Austin, TX",
email: "kyle@carberry.com",
hireable: null,
bio: "hey there",
twitter_username: "kylecarbs",
public_repos: 52,
public_gists: 9,
followers: 208,
following: 31,
created_at: "2014-04-01T02:24:41Z",
updated_at: "2023-06-26T13:03:09Z",
test.beforeEach(async ({ context, page }) => {
beforeCoderTest(page);
await login(page);
await resetExternalAuthKey(context);
});
// Ensures that a Git auth provider with the device flow functions and completes!
test("external auth device", async ({ page }) => {
const device: ExternalAuthDevice = {
device_code: "1234",
user_code: "1234-5678",
expires_in: 900,
interval: 1,
verification_uri: "",
};
const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] =
{
installations: [
{
id: 1,
access_tokens_url: "",
account: ghUser,
app_id: 1,
app_slug: "coder",
created_at: "2014-04-01T02:24:41Z",
events: [],
html_url: "",
permissions: {},
repositories_url: "",
repository_selection: "all",
single_file_name: "",
suspended_at: null,
suspended_by: null,
target_id: 1,
target_type: "",
updated_at: "2023-06-26T13:03:09Z",
},
],
total_count: 1,
};
// Start a server to mock the GitHub API.
const srv = await createServer(gitAuth.devicePort);
srv.use(gitAuth.validatePath, (_req, res) => {
res.write(JSON.stringify(ghUser));
res.end();
});
srv.use(gitAuth.codePath, (_req, res) => {
res.write(JSON.stringify(device));
res.end();
});
srv.use(gitAuth.installationsPath, (_req, res) => {
res.write(JSON.stringify(ghInstall));
res.end();
});
const token = {
access_token: "",
error: "authorization_pending",
error_description: "",
};
// First we send a result from the API that the token hasn't been
// authorized yet to ensure the UI reacts properly.
const sentPending = new Awaiter();
srv.use(gitAuth.tokenPath, (_req, res) => {
res.write(JSON.stringify(token));
res.end();
sentPending.done();
});
await page.goto(`/external-auth/${gitAuth.deviceProvider}`, {
waitUntil: "domcontentloaded",
});
await page.getByText(device.user_code).isVisible();
await sentPending.wait();
// Update the token to be valid and ensure the UI updates!
token.error = "";
token.access_token = "hello-world";
await page.waitForSelector("text=1 organization authorized");
});
test("external auth web", async ({ page }) => {
await page.goto(`/external-auth/${gitAuth.webProvider}`, {
waitUntil: "domcontentloaded",
});
// This endpoint doesn't have the installations URL set intentionally!
await page.waitForSelector("text=You've authenticated with GitHub!");
});
test("successful external auth from workspace", async ({ page }) => {
const templateName = await createTemplate(
page,
echoResponsesWithExternalAuth([
{ id: gitAuth.webProvider, optional: false },
]),
);
await createWorkspace(page, templateName, { useExternalAuth: true });
});
const ghUser: Endpoints["GET /user"]["response"]["data"] = {
login: "kylecarbs",
id: 7122116,
node_id: "MDQ6VXNlcjcxMjIxMTY=",
avatar_url: "https://avatars.githubusercontent.com/u/7122116?v=4",
gravatar_id: "",
url: "https://api.github.com/users/kylecarbs",
html_url: "https://github.com/kylecarbs",
followers_url: "https://api.github.com/users/kylecarbs/followers",
following_url:
"https://api.github.com/users/kylecarbs/following{/other_user}",
gists_url: "https://api.github.com/users/kylecarbs/gists{/gist_id}",
starred_url:
"https://api.github.com/users/kylecarbs/starred{/owner}{/repo}",
subscriptions_url: "https://api.github.com/users/kylecarbs/subscriptions",
organizations_url: "https://api.github.com/users/kylecarbs/orgs",
repos_url: "https://api.github.com/users/kylecarbs/repos",
events_url: "https://api.github.com/users/kylecarbs/events{/privacy}",
received_events_url:
"https://api.github.com/users/kylecarbs/received_events",
type: "User",
site_admin: false,
name: "Kyle Carberry",
company: "@coder",
blog: "https://carberry.com",
location: "Austin, TX",
email: "kyle@carberry.com",
hireable: null,
bio: "hey there",
twitter_username: "kylecarbs",
public_repos: 52,
public_gists: 9,
followers: 208,
following: 31,
created_at: "2014-04-01T02:24:41Z",
updated_at: "2023-06-26T13:03:09Z",
};
const ghInstall: Endpoints["GET /user/installations"]["response"]["data"] = {
installations: [
{
id: 1,
access_tokens_url: "",
account: ghUser,
app_id: 1,
app_slug: "coder",
created_at: "2014-04-01T02:24:41Z",
events: [],
html_url: "",
permissions: {},
repositories_url: "",
repository_selection: "all",
single_file_name: "",
suspended_at: null,
suspended_by: null,
target_id: 1,
target_type: "",
updated_at: "2023-06-26T13:03:09Z",
},
],
total_count: 1,
};
});
+1 -1
View File
@@ -127,7 +127,7 @@
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/plugin-syntax-typescript": "7.28.6",
"@biomejs/biome": "2.4.10",
"@biomejs/biome": "2.2.4",
"@chromatic-com/storybook": "5.0.1",
"@octokit/types": "12.6.0",
"@playwright/test": "1.50.1",
+40 -40
View File
@@ -276,8 +276,8 @@ importers:
specifier: 7.28.6
version: 7.28.6(@babel/core@7.29.0)
'@biomejs/biome':
specifier: 2.4.10
version: 2.4.10
specifier: 2.2.4
version: 2.2.4
'@chromatic-com/storybook':
specifier: 5.0.1
version: 5.0.1(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))
@@ -469,7 +469,7 @@ importers:
version: 8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0)
vite-plugin-checker:
specifier: 0.12.0
version: 0.12.0(@biomejs/biome@2.4.10)(optionator@0.9.3)(typescript@6.0.2)(vite@8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0))
version: 0.12.0(@biomejs/biome@2.2.4)(optionator@0.9.3)(typescript@6.0.2)(vite@8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0))
vitest:
specifier: 4.1.1
version: 4.1.1(@types/node@20.19.25)(@vitest/browser-playwright@4.1.1)(jsdom@27.2.0)(msw@2.4.8(typescript@6.0.2))(vite@8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0))
@@ -708,55 +708,55 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, tarball: https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz}
'@biomejs/biome@2.4.10':
resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==, tarball: https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.10.tgz}
'@biomejs/biome@2.2.4':
resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==, tarball: https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.4.tgz}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.4.10':
resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz}
'@biomejs/cli-darwin-arm64@2.2.4':
resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.4.10':
resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.10.tgz}
'@biomejs/cli-darwin-x64@2.2.4':
resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.4.10':
resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.10.tgz}
'@biomejs/cli-linux-arm64-musl@2.2.4':
resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.4.10':
resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.10.tgz}
'@biomejs/cli-linux-arm64@2.2.4':
resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.4.10':
resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.10.tgz}
'@biomejs/cli-linux-x64-musl@2.2.4':
resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.4.10':
resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.10.tgz}
'@biomejs/cli-linux-x64@2.2.4':
resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.4.10':
resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.10.tgz}
'@biomejs/cli-win32-arm64@2.2.4':
resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.4.10':
resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.10.tgz}
'@biomejs/cli-win32-x64@2.2.4':
resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.4.tgz}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -7697,39 +7697,39 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
'@biomejs/biome@2.4.10':
'@biomejs/biome@2.2.4':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.10
'@biomejs/cli-darwin-x64': 2.4.10
'@biomejs/cli-linux-arm64': 2.4.10
'@biomejs/cli-linux-arm64-musl': 2.4.10
'@biomejs/cli-linux-x64': 2.4.10
'@biomejs/cli-linux-x64-musl': 2.4.10
'@biomejs/cli-win32-arm64': 2.4.10
'@biomejs/cli-win32-x64': 2.4.10
'@biomejs/cli-darwin-arm64': 2.2.4
'@biomejs/cli-darwin-x64': 2.2.4
'@biomejs/cli-linux-arm64': 2.2.4
'@biomejs/cli-linux-arm64-musl': 2.2.4
'@biomejs/cli-linux-x64': 2.2.4
'@biomejs/cli-linux-x64-musl': 2.2.4
'@biomejs/cli-win32-arm64': 2.2.4
'@biomejs/cli-win32-x64': 2.2.4
'@biomejs/cli-darwin-arm64@2.4.10':
'@biomejs/cli-darwin-arm64@2.2.4':
optional: true
'@biomejs/cli-darwin-x64@2.4.10':
'@biomejs/cli-darwin-x64@2.2.4':
optional: true
'@biomejs/cli-linux-arm64-musl@2.4.10':
'@biomejs/cli-linux-arm64-musl@2.2.4':
optional: true
'@biomejs/cli-linux-arm64@2.4.10':
'@biomejs/cli-linux-arm64@2.2.4':
optional: true
'@biomejs/cli-linux-x64-musl@2.4.10':
'@biomejs/cli-linux-x64-musl@2.2.4':
optional: true
'@biomejs/cli-linux-x64@2.4.10':
'@biomejs/cli-linux-x64@2.2.4':
optional: true
'@biomejs/cli-win32-arm64@2.4.10':
'@biomejs/cli-win32-arm64@2.2.4':
optional: true
'@biomejs/cli-win32-x64@2.4.10':
'@biomejs/cli-win32-x64@2.2.4':
optional: true
'@blazediff/core@1.9.1': {}
@@ -15034,7 +15034,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-checker@0.12.0(@biomejs/biome@2.4.10)(optionator@0.9.3)(typescript@6.0.2)(vite@8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0)):
vite-plugin-checker@0.12.0(@biomejs/biome@2.2.4)(optionator@0.9.3)(typescript@6.0.2)(vite@8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0)):
dependencies:
'@babel/code-frame': 7.29.0
chokidar: 4.0.3
@@ -15046,7 +15046,7 @@ snapshots:
vite: 8.0.2(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@20.19.25)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.7.0)
vscode-uri: 3.1.0
optionalDependencies:
'@biomejs/biome': 2.4.10
'@biomejs/biome': 2.2.4
optionator: 0.9.3
typescript: 6.0.2
+12 -6
View File
@@ -147,9 +147,12 @@ describe("api.ts", () => {
{ q: "owner:me" },
"/api/v2/workspaces?q=owner%3Ame",
],
])("Workspaces - getURLWithSearchParams(%p, %p) returns %p", (basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected);
});
])(
"Workspaces - getURLWithSearchParams(%p, %p) returns %p",
(basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected);
},
);
});
describe("getURLWithSearchParams - users", () => {
@@ -161,9 +164,12 @@ describe("api.ts", () => {
"/api/v2/users?q=status%3Aactive",
],
["/api/v2/users", { q: "" }, "/api/v2/users"],
])("Users - getURLWithSearchParams(%p, %p) returns %p", (basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected);
});
])(
"Users - getURLWithSearchParams(%p, %p) returns %p",
(basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected);
},
);
});
describe("update", () => {
+3 -2
View File
@@ -3170,13 +3170,14 @@ class ExperimentalApiMethods {
chatId: string,
messageId: number,
req: TypesGen.EditChatMessageRequest,
): Promise<TypesGen.EditChatMessageResponse> => {
const response = await this.axios.patch<TypesGen.EditChatMessageResponse>(
): Promise<TypesGen.ChatMessage> => {
const response = await this.axios.patch<TypesGen.ChatMessage>(
`/api/experimental/chats/${chatId}/messages/${messageId}`,
req,
);
return response.data;
};
interruptChat = async (chatId: string): Promise<TypesGen.Chat> => {
const response = await this.axios.post<TypesGen.Chat>(
`/api/experimental/chats/${chatId}/interrupt`,
-39
View File
@@ -913,7 +913,6 @@ export interface AuditLog {
export interface AuditLogResponse {
readonly audit_logs: readonly AuditLog[];
readonly count: number;
readonly count_cap: number;
}
// From codersdk/audit.go
@@ -1201,7 +1200,6 @@ export interface Chat {
readonly pin_order: number;
readonly mcp_server_ids: readonly string[];
readonly labels: Record<string, string>;
readonly files?: readonly ChatFileMetadata[];
/**
* HasUnread is true when assistant messages exist beyond
* the owner's read cursor, which updates on stream
@@ -1215,7 +1213,6 @@ export interface Chat {
* attach or agent change.
*/
readonly last_injected_context?: readonly ChatMessagePart[];
readonly warnings?: readonly string[];
}
// From codersdk/chats.go
@@ -1410,20 +1407,6 @@ export interface ChatDiffStatus {
readonly stale_at?: string;
}
// From codersdk/chats.go
/**
* ChatFileMetadata contains lightweight metadata about a file
* associated with a chat, excluding the file content itself.
*/
export interface ChatFileMetadata {
readonly id: string;
readonly owner_id: string;
readonly organization_id: string;
readonly name: string;
readonly mime_type: string;
readonly created_at: string;
}
// From codersdk/chats.go
export interface ChatFilePart {
readonly type: "file";
@@ -2270,7 +2253,6 @@ export interface ConnectionLog {
export interface ConnectionLogResponse {
readonly connection_logs: readonly ConnectionLog[];
readonly count: number;
readonly count_cap: number;
}
// From codersdk/connectionlog.go
@@ -2372,7 +2354,6 @@ export interface CreateChatMessageResponse {
readonly message?: ChatMessage;
readonly queued_message?: ChatQueuedMessage;
readonly queued: boolean;
readonly warnings?: readonly string[];
}
// From codersdk/chats.go
@@ -3224,17 +3205,6 @@ export interface EditChatMessageRequest {
readonly content: readonly ChatInputPart[];
}
// From codersdk/chats.go
/**
* EditChatMessageResponse is the response from editing a message in a chat.
* Edits are always synchronous (no queueing), so the message is returned
* directly.
*/
export interface EditChatMessageResponse {
readonly message: ChatMessage;
readonly warnings?: readonly string[];
}
// From codersdk/externalauth.go
export type EnhancedExternalAuthProvider =
| "azure-devops"
@@ -4142,15 +4112,6 @@ export interface MatchedProvisioners {
readonly most_recently_seen?: string;
}
// From codersdk/chats.go
/**
* MaxChatFileIDs is the maximum number of file IDs that can be
* associated with a single chat. This limit prevents unbounded
* growth in the chat_file_links table. It is easier to raise
* this limit than to lower it.
*/
export const MaxChatFileIDs = 20;
// From codersdk/organizations.go
export interface MinimalOrganization {
readonly id: string;
@@ -10,4 +10,4 @@ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+1 -1
View File
@@ -67,4 +67,4 @@ export const DialogActionButtons: FC<DialogActionButtonsProps> = ({
* Re-export of MUI's Dialog component, for convenience.
* @link See original documentation here: https://mui.com/material-ui/react-dialog/
*/
export { type DialogProps, MuiDialog as Dialog };
export { MuiDialog as Dialog, type DialogProps };
+2 -2
View File
@@ -306,7 +306,7 @@ const PresetMenu: FC<PresetMenuProps> = ({
{(learnMoreLink || learnMoreLink2) && <DropdownMenuSeparator />}
{learnMoreLink && (
<DropdownMenuItem asChild>
<a href={learnMoreLink} target="_blank" rel="noreferrer">
<a href={learnMoreLink} target="_blank">
<ExternalLinkIcon className="size-icon-xs" />
View advanced filtering
</a>
@@ -314,7 +314,7 @@ const PresetMenu: FC<PresetMenuProps> = ({
)}
{learnMoreLink2 && learnMoreLabel2 && (
<DropdownMenuItem asChild>
<a href={learnMoreLink2} target="_blank" rel="noreferrer">
<a href={learnMoreLink2} target="_blank">
<ExternalLinkIcon className="size-icon-xs" />
{learnMoreLabel2}
</a>
@@ -24,10 +24,6 @@ export const FormField: FC<FormFieldProps> = ({
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Input
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
{...inputProps}
id={id}
aria-invalid={field.error}
+5 -4
View File
@@ -2,12 +2,13 @@
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/input}
*/
import type { ComponentPropsWithRef, FC } from "react";
import { cn } from "#/utils/cn";
export type InputProps = ComponentPropsWithRef<"input">;
export const Input: FC<InputProps> = ({ className, type, ...props }) => {
export const Input: React.FC<React.ComponentPropsWithRef<"input">> = ({
className,
type,
...props
}) => {
return (
<input
type={type}
-1
View File
@@ -24,5 +24,4 @@ function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
/>
);
}
export { Kbd, KbdGroup };
+1 -1
View File
@@ -58,7 +58,7 @@ export const Markdown: FC<MarkdownProps> = (props) => {
},
pre: ({ node, children }) => {
if (!node?.children) {
if (!node || !node.children) {
return <pre>{children}</pre>;
}
const firstChild = node.children[0];
@@ -7,7 +7,6 @@ type PaginationHeaderProps = {
limit: number;
totalRecords: number | undefined;
currentOffsetStart: number | undefined;
countIsCapped?: boolean;
// Temporary escape hatch until Workspaces can be switched over to using
// PaginationContainer
@@ -19,7 +18,6 @@ export const PaginationAmount: FC<PaginationHeaderProps> = ({
limit,
totalRecords,
currentOffsetStart,
countIsCapped,
className,
}) => {
const theme = useTheme();
@@ -54,16 +52,10 @@ export const PaginationAmount: FC<PaginationHeaderProps> = ({
<strong>
{(
currentOffsetStart +
(countIsCapped
? limit - 1
: Math.min(limit - 1, totalRecords - currentOffsetStart))
Math.min(limit - 1, totalRecords - currentOffsetStart)
).toLocaleString()}
</strong>{" "}
of{" "}
<strong>
{totalRecords.toLocaleString()}
{countIsCapped && "+"}
</strong>{" "}
of <strong>{totalRecords.toLocaleString()}</strong>{" "}
{paginationUnitLabel}
</div>
)}
@@ -18,7 +18,6 @@ export const mockPaginationResultBase: ResultBase = {
limit: 25,
hasNextPage: false,
hasPreviousPage: false,
countIsCapped: false,
goToPreviousPage: () => {},
goToNextPage: () => {},
goToFirstPage: () => {},
@@ -34,7 +33,6 @@ export const mockInitialRenderResult: PaginationResult = {
hasPreviousPage: false,
totalRecords: undefined,
totalPages: undefined,
countIsCapped: false,
};
export const mockSuccessResult: PaginationResult = {
@@ -94,7 +94,7 @@ export const FirstPageWithTonsOfData: Story = {
currentPage: 2,
currentOffsetStart: 1000,
totalRecords: 123_456,
totalPages: 4939,
totalPages: 1235,
hasPreviousPage: false,
hasNextPage: true,
isPlaceholderData: false,
@@ -135,54 +135,3 @@ export const SecondPageWithData: Story = {
children: <div>New data for page 2</div>,
},
};
export const CappedCountFirstPage: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 1,
currentOffsetStart: 1,
totalRecords: 2000,
totalPages: 80,
hasPreviousPage: false,
hasNextPage: true,
isPlaceholderData: false,
countIsCapped: true,
},
},
};
export const CappedCountMiddlePage: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 3,
currentOffsetStart: 51,
totalRecords: 2000,
totalPages: 80,
hasPreviousPage: true,
hasNextPage: true,
isPlaceholderData: false,
countIsCapped: true,
},
},
};
export const CappedCountBeyondKnownPages: Story = {
args: {
query: {
...mockPaginationResultBase,
isSuccess: true,
currentPage: 85,
currentOffsetStart: 2101,
totalRecords: 2000,
totalPages: 85,
hasPreviousPage: true,
hasNextPage: true,
isPlaceholderData: false,
countIsCapped: true,
},
},
};
@@ -27,14 +27,12 @@ export const PaginationContainer: FC<PaginationProps> = ({
totalRecords={query.totalRecords}
currentOffsetStart={query.currentOffsetStart}
paginationUnitLabel={paginationUnitLabel}
countIsCapped={query.countIsCapped}
className="justify-end"
/>
{query.isSuccess && (
<PaginationWidgetBase
totalRecords={query.totalRecords}
totalPages={query.totalPages}
currentPage={query.currentPage}
pageSize={query.limit}
onPageChange={query.onPageChange}
@@ -12,10 +12,6 @@ export type PaginationWidgetBaseProps = {
hasPreviousPage?: boolean;
hasNextPage?: boolean;
/** Override the computed totalPages.
* Used when, e.g., the row count is capped and the user navigates beyond
* the known range, so totalPages stays at least as high as currentPage. */
totalPages?: number;
};
export const PaginationWidgetBase: FC<PaginationWidgetBaseProps> = ({
@@ -25,9 +21,8 @@ export const PaginationWidgetBase: FC<PaginationWidgetBaseProps> = ({
onPageChange,
hasPreviousPage,
hasNextPage,
totalPages: totalPagesProp,
}) => {
const totalPages = totalPagesProp ?? Math.ceil(totalRecords / pageSize);
const totalPages = Math.ceil(totalRecords / pageSize);
if (totalPages < 2) {
return null;
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { PasswordField } from "./PasswordField";
@@ -9,13 +9,6 @@ const meta: Meta<typeof PasswordField> = {
component: PasswordField,
args: {
label: "Password",
field: {
id: "password",
name: "password",
error: false,
onBlur: fn(),
onChange: fn(),
},
},
render: function StatefulPasswordField(args) {
const [value, setValue] = useState("");
@@ -1,33 +1,21 @@
import TextField, { type TextFieldProps } from "@mui/material/TextField";
import type { FC } from "react";
import { keepPreviousData, useQuery } from "react-query";
import { API } from "#/api/api";
import { Input, type InputProps } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { useDebouncedValue } from "#/hooks/debounce";
import { cn } from "#/utils/cn";
import type { FormHelpers } from "#/utils/formUtils";
// TODO: @BrunoQuaresma: Unable to integrate Yup + Formik for validation. The
// validation was triggering on the onChange event, but the form.errors were not
// updating accordingly. Tried various combinations of validateOnBlur and
// validateOnChange without success. Further investigation is needed.
type PasswordFieldProps = InputProps & {
label: string;
field: FormHelpers;
};
/**
* A password field component that validates the password against the API with
* debounced calls. It uses a debounced value to minimize the number of API
* calls and displays validation errors.
*/
export const PasswordField: FC<PasswordFieldProps> = ({
label,
field,
value,
...props
}) => {
const debouncedValue = useDebouncedValue(`${value}`, 500);
export const PasswordField: FC<TextFieldProps> = (props) => {
const debouncedValue = useDebouncedValue(`${props.value}`, 500);
const validatePasswordQuery = useQuery({
queryKey: ["validatePassword", debouncedValue],
queryFn: () => API.validateUserPassword(debouncedValue),
@@ -36,33 +24,14 @@ export const PasswordField: FC<PasswordFieldProps> = ({
});
const valid = validatePasswordQuery.data?.valid ?? true;
const displayHelper = !valid
? validatePasswordQuery.data?.details
: field.helperText;
return (
<div className="flex flex-col items-start gap-2">
<Label htmlFor={field.id}>{label}</Label>
<Input
id={field.id}
type="password"
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
{...props}
aria-invalid={!valid || undefined}
/>
{displayHelper && (
<span
className={cn(
"text-xs text-left",
valid ? "text-content-secondary" : "text-content-destructive",
)}
>
{displayHelper}
</span>
)}
</div>
<TextField
{...props}
type="password"
error={!valid || props.error}
helperText={
!valid ? validatePasswordQuery.data?.details : props.helperText
}
/>
);
};
+69 -51
View File
@@ -108,11 +108,23 @@ describe("ProxyContextGetURLs", () => {
MockHealthyWildWorkspaceProxy.path_app_url,
MockHealthyWildWorkspaceProxy.wildcard_hostname,
],
])("%p", (_, regions, latencies, selected, preferredPathAppURL, preferredWildcardHostname) => {
const preferred = getPreferredProxy(regions, selected, latencies, true);
expect(preferred.preferredPathAppURL).toBe(preferredPathAppURL);
expect(preferred.preferredWildcardHostname).toBe(preferredWildcardHostname);
});
])(
"%p",
(
_,
regions,
latencies,
selected,
preferredPathAppURL,
preferredWildcardHostname,
) => {
const preferred = getPreferredProxy(regions, selected, latencies, true);
expect(preferred.preferredPathAppURL).toBe(preferredPathAppURL);
expect(preferred.preferredWildcardHostname).toBe(
preferredWildcardHostname,
);
},
);
});
const TestingComponent = () => {
@@ -330,56 +342,62 @@ describe("ProxyContextSelection", () => {
},
},
],
] as [string, ProxyContextSelectionTest][])("%s", async (_, {
expUserProxyID,
expProxyID: expSelectedProxyID,
regions,
storageProxy,
latencies = {},
afterLoad,
}) => {
// Mock the latencies
hardCodedLatencies = latencies;
] as [string, ProxyContextSelectionTest][])(
"%s",
async (
_,
{
expUserProxyID,
expProxyID: expSelectedProxyID,
regions,
storageProxy,
latencies = {},
afterLoad,
},
) => {
// Mock the latencies
hardCodedLatencies = latencies;
// Initial selection if present
if (storageProxy) {
saveUserSelectedProxy(storageProxy);
}
// Initial selection if present
if (storageProxy) {
saveUserSelectedProxy(storageProxy);
}
// Mock the API response
server.use(
http.get("/api/v2/regions", () =>
HttpResponse.json({
regions,
}),
),
http.get("/api/v2/workspaceproxies", () =>
HttpResponse.json({ regions }),
),
);
// Mock the API response
server.use(
http.get("/api/v2/regions", () =>
HttpResponse.json({
regions,
}),
),
http.get("/api/v2/workspaceproxies", () =>
HttpResponse.json({ regions }),
),
);
TestingComponent();
await waitForLoaderToBeRemoved();
TestingComponent();
await waitForLoaderToBeRemoved();
await screen.findByTestId("latenciesLoaded").then((x) => {
expect(x.title).toBe("true");
});
await screen.findByTestId("latenciesLoaded").then((x) => {
expect(x.title).toBe("true");
});
if (afterLoad) {
await afterLoad();
}
if (afterLoad) {
await afterLoad();
}
await screen.findByTestId("isFetched").then((x) => {
expect(x.title).toBe("true");
});
await screen.findByTestId("isLoading").then((x) => {
expect(x.title).toBe("false");
});
await screen.findByTestId("preferredProxy").then((x) => {
expect(x.title).toBe(expSelectedProxyID);
});
await screen.findByTestId("userProxy").then((x) => {
expect(x.title).toBe(expUserProxyID || "");
});
});
await screen.findByTestId("isFetched").then((x) => {
expect(x.title).toBe("true");
});
await screen.findByTestId("isLoading").then((x) => {
expect(x.title).toBe("false");
});
await screen.findByTestId("preferredProxy").then((x) => {
expect(x.title).toBe(expSelectedProxyID);
});
await screen.findByTestId("userProxy").then((x) => {
expect(x.title).toBe(expUserProxyID || "");
});
},
);
});
+1 -1
View File
@@ -228,7 +228,7 @@ export const getPreferredProxy = (
);
// If no proxy is selected, or the selected proxy is unhealthy default to the primary proxy.
if (!selectedProxy?.healthy) {
if (!selectedProxy || !selectedProxy.healthy) {
// Default to the primary proxy
selectedProxy = proxies.find((proxy) => proxy.name === "primary");
+1 -1
View File
@@ -241,7 +241,7 @@ export function makeUseEmbeddedMetadata(
metadata,
clearMetadataByKey: manager.clearMetadataByKey,
};
}, [metadata]);
}, [manager, metadata]);
return stableMetadataResult;
};
-72
View File
@@ -258,78 +258,6 @@ describe(usePaginatedQuery.name, () => {
});
});
describe("Capped count behavior", () => {
const mockQueryKey = vi.fn(() => ["mock"]);
// Returns count 2001 (capped) with items on pages up to page 84
// (84 * 25 = 2100 items total).
const mockCappedQueryFn = vi.fn(({ pageNumber, limit }) => {
const totalItems = 2100;
const offset = (pageNumber - 1) * limit;
// Returns 0 items when the requested page is past the end, simulating
// an empty server response.
const itemsOnPage = Math.max(0, Math.min(limit, totalItems - offset));
return Promise.resolve({
data: new Array(itemsOnPage).fill(pageNumber),
count: 2001,
count_cap: 2000,
});
});
it("Caps totalRecords at 2000 when count exceeds cap", async () => {
const { result } = await render({
queryKey: mockQueryKey,
queryFn: mockCappedQueryFn,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.totalRecords).toBe(2000);
});
it("hasNextPage is true when count is capped", async () => {
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockCappedQueryFn },
"/?page=80",
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasNextPage).toBe(true);
});
it("hasPreviousPage is true when count is capped and page is beyond cap", async () => {
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockCappedQueryFn },
"/?page=83",
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasPreviousPage).toBe(true);
});
it("Does not redirect to last page when count is capped and page is valid", async () => {
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockCappedQueryFn },
"/?page=83",
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Should stay on page 83 — not redirect to page 80.
expect(result.current.currentPage).toBe(83);
});
it("Redirects to last known page when navigating beyond actual data", async () => {
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockCappedQueryFn },
"/?page=999",
);
// Page 999 has no items. Should redirect to page 81
// (ceil(2001 / 25) = 81), the last page guaranteed to
// have data.
await waitFor(() => expect(result.current.currentPage).toBe(81));
});
});
describe("Passing in searchParams property", () => {
const mockQueryKey = vi.fn(() => ["mock"]);
const mockQueryFn = vi.fn(({ pageNumber, limit }) =>

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