Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e39c106fa9 | |||
| 9b281d2347 | |||
| 4ca34441e7 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
Generated
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
-6
@@ -12739,9 +12739,6 @@
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"count_cap": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13042,9 +13039,6 @@
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"count_cap": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+1
-8
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
-16
@@ -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'
|
||||
);
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
-2
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Generated
+1
-2
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Generated
+2
-6
@@ -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
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
version = "~> 4.0"
|
||||
version = "~> 3.0"
|
||||
}
|
||||
envbuilder = {
|
||||
source = "coder/envbuilder"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+37
-37
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+40
-40
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Generated
-39
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -24,5 +24,4 @@ function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 || "");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ export function makeUseEmbeddedMetadata(
|
||||
metadata,
|
||||
clearMetadataByKey: manager.clearMetadataByKey,
|
||||
};
|
||||
}, [metadata]);
|
||||
}, [manager, metadata]);
|
||||
|
||||
return stableMetadataResult;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user