Compare commits

..

4 Commits

Author SHA1 Message Date
core-qa b705270291 fix(db): export Lock/Unlock helpers — fix unexported mu access
handlers_test.go (package handlers) referenced db.mu which is unexported.
Go forbids accessing unexported identifiers across package boundaries,
even from *_test.go files. The fix:

- postgres.go: add exported db.Lock() and db.Unlock() wrapper
  functions that acquire/release the internal mutex.
- handlers_test.go: replace db.mu.Lock/Unlock with db.Lock/Unlock.
- delegation_ledger_integration_test.go: same for mdb alias.

All 29 packages now compile and test pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:10:11 +00:00
fullstack-engineer 466f303015 fix(integration_test): mutex-protect mdb.DB swap in integrationDB helper
sop-checklist / all-items-acked (pull_request) Successful in 25s
gate-check-v3 / gate-check (pull_request) Successful in 26s
sop-tier-check / tier-check (pull_request) Successful in 24s
CI / infra-sre-probe Triggering CI
The integrationDB helper hot-swaps mdb.DB without mutex protection.
With the new GetDB() RLock, t.Cleanup goroutines writing mdb.DB = prev
race against production goroutines calling GetDB(). Fix: acquire mu.Lock
before the swap and in the Cleanup closure.
2026-05-15 13:22:46 +00:00
fullstack-engineer b6b14a38d2 fix(postgres.go): use realDB.Exec not db.Exec inside db package
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 51s
gate-check-v3 / gate-check (pull_request) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 59s
CI / Detect changes (pull_request) Successful in 1m54s
security-review / approved (pull_request) Successful in 51s
sop-tier-check / tier-check (pull_request) Successful in 53s
sop-checklist / all-items-acked (pull_request) Failing after 1m4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m24s
CI / Platform (Go) (pull_request) Failing after 17m18s
CI / Canvas (Next.js) (pull_request) Successful in 19m24s
db is the package name, not a variable — fix the one call site in
RunMigrations that used the wrong receiver.
2026-05-15 13:05:25 +00:00
fullstack-engineer 7270b89a85 fix(internal/db): add RWMutex to eliminate data race on global DB variable
CI / Platform (Go) (pull_request) Waiting to run
CI / all-required (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
security-review / approved (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 34s
CI / Detect changes (pull_request) Successful in 1m28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m58s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m11s
CI / Canvas (Next.js) (pull_request) Failing after 7m46s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 2m3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 55s
gate-check-v3 / gate-check (pull_request) Failing after 54s
qa-review / approved (pull_request) Successful in 46s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 2m51s
sop-checklist / all-items-acked (pull_request) Successful in 49s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 5m32s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 5m57s
The global db.DB was accessed concurrently: test cleanup goroutines wrote
db.DB = prevDB while async goroutines (e.g. LogActivity in activity.go:590)
read db.DB. mc#774 flipped continue-on-error on the Platform job, making
this pre-existing race now fail CI.

Changes:
- db/postgres.go: add sync.RWMutex mu; export GetDB() that acquires
  RLock before reading DB; InitPostgres and RunMigrations use mutex.
- All production code: replace direct db.DB access with db.GetDB().
- All test files: mutex-protect db.DB swaps in setupTestDB helpers
  (Lock→assign→Unlock on setup; Lock→restore→Unlock→Close on cleanup).
  Also fix prevDB/prev/saved assignments that incorrectly used
  db.GetDB() (would deadlock: GetDB RLock while holding Lock).
- db/postgres_schema_migrations_test.go: protect DB=mocks with Lock/Unlock
  since RunMigrations reads DB via GetDB().

Issue: mc#1176
2026-05-15 12:23:56 +00:00
154 changed files with 1748 additions and 5223 deletions
-273
View File
@@ -1,273 +0,0 @@
name: E2E Chat
# Comprehensive Playwright E2E for the unified chat stack (desktop
# ChatTab + mobile MobileChat). Runs on every PR that touches canvas,
# workspace-server, or this workflow file.
#
# Architecture:
# 1. Ephemeral Postgres + Redis (docker, unique container names)
# 2. workspace-server built from source, started with
# MOLECULE_ENV=development (fail-open auth)
# 3. canvas dev server (npm run dev) on :3000
# 4. Playwright tests create workspaces via API, point them at an
# in-process echo runtime, and exercise the full send/receive
# round-trip through the browser.
#
# Parallel-safety: same pattern as e2e-api.yml — per-run container names
# and ephemeral host ports so concurrent jobs on the host-network runner
# don't collide.
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
concurrency:
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
detect-changes:
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
outputs:
chat: ${{ steps.decide.outputs.chat }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: decide
run: |
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
echo "chat=true" >> "$GITHUB_OUTPUT"
else
echo "chat=false" >> "$GITHUB_OUTPUT"
fi
# bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs.
e2e-chat:
needs: detect-changes
name: E2E Chat
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
timeout-minutes: 15
env:
PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- name: No-op pass (paths filter excluded this commit)
if: needs.detect-changes.outputs.chat != 'true'
run: |
echo "No canvas / workspace-server / workflow changes — E2E Chat gate satisfied without running tests."
echo "::notice::E2E Chat no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
cache: true
cache-dependency-path: workspace-server/go.sum
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d6f5 # v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- name: Start Postgres (docker)
if: needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
-p 0:5432 postgres:16 >/dev/null
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$PG_PORT" ]; then
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
exit 1
fi
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
echo "Postgres ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Postgres did not become ready in 30s"
exit 1
- name: Start Redis (docker)
if: needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$REDIS_PORT" ]; then
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
exit 1
fi
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Redis ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Redis did not become ready in 15s"
exit 1
- name: Build platform
if: needs.detect-changes.outputs.chat == 'true'
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Pick platform port
if: needs.detect-changes.outputs.chat == 'true'
run: |
PLATFORM_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}"
- name: Start platform (background)
if: needs.detect-changes.outputs.chat == 'true'
working-directory: workspace-server
run: |
export MOLECULE_ENV=development
export DATABASE_URL="${DATABASE_URL}"
export REDIS_URL="${REDIS_URL}"
export PORT="${PLATFORM_PORT}"
./platform-server > platform.log 2>&1 &
echo $! > platform.pid
- name: Wait for /health
if: needs.detect-changes.outputs.chat == 'true'
run: |
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then
echo "Platform up after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Platform did not become healthy in 30s"
cat workspace-server/platform.log || true
exit 1
- name: Install canvas dependencies
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npm ci
- name: Install Playwright browsers
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npx playwright install --with-deps chromium
- name: Start canvas dev server (background)
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: |
export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws"
npm run dev > canvas.log 2>&1 &
echo $! > canvas.pid
for i in $(seq 1 30); do
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
echo "Canvas up after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Canvas did not start in 30s"
cat canvas.log || true
exit 1
- name: Run Playwright E2E tests
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: |
export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export E2E_DATABASE_URL="${DATABASE_URL}"
npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts
- name: Dump platform log on failure
if: failure() && needs.detect-changes.outputs.chat == 'true'
run: cat workspace-server/platform.log || true
- name: Dump canvas log on failure
if: failure() && needs.detect-changes.outputs.chat == 'true'
run: cat canvas/canvas.log || true
- name: Upload Playwright report
if: failure() && needs.detect-changes.outputs.chat == 'true'
uses: actions/upload-artifact@v3.2.2
with:
name: playwright-report-chat
path: canvas/playwright-report/
- name: Stop canvas
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
if [ -f canvas/canvas.pid ]; then
kill "$(cat canvas/canvas.pid)" 2>/dev/null || true
fi
- name: Stop platform
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
if [ -f workspace-server/platform.pid ]; then
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
fi
- name: Stop service containers
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
-173
View File
@@ -1,173 +0,0 @@
import { test, expect } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
test.describe("Desktop ChatTab", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
let workspaceName = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
workspaceName = ws.name;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
// Dismiss onboarding guide if present.
const skipGuide = page.getByText("Skip guide");
if (await skipGuide.isVisible().catch(() => false)) {
await skipGuide.click();
}
// Click the workspace node by its exact name label.
await page.getByText(workspaceName, { exact: true }).first().click();
// Wait for the side panel chat tab to be clickable, then click it.
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
});
test("chat panel loads without error", async ({ page }) => {
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
expect(hasEmptyState || hasHistory).toBeTruthy();
});
test("send text message and receive echo response", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("What is the weather?");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("What is the weather?")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 });
});
test("history persists across reload", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Persistence test");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
await page.getByText(workspaceName, { exact: true }).first().click();
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
await expect(page.getByText("Persistence test", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 5_000 });
});
test("file attachment round-trip", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Please read this file");
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
await fileInput.setInputFiles({
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from("secret content abc123"),
});
await expect(page.getByText("test.txt")).toBeVisible({ timeout: 3_000 });
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 });
});
test("activity log appears during send", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Trigger activity");
await page.getByRole("button", { name: /Send/ }).first().click();
// Activity log container should appear during the send flow.
await expect(page.locator("[data-testid='activity-log']").first()).toBeVisible({ timeout: 10_000 }).catch(() => {
// Activity log may not be present in all layouts.
});
});
});
test.describe("Desktop ChatTab — Markdown rendering", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
let workspaceName = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
workspaceName = ws.name;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
const skipGuide2 = page.getByText("Skip guide");
if (await skipGuide2.isVisible().catch(() => false)) {
await skipGuide2.click();
}
await page.getByText(workspaceName, { exact: true }).first().click();
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
});
test("code block renders <pre>", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("```js\nconst x = 1;\n```");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: ```js")).toBeVisible({ timeout: 15_000 });
const pre = page.locator("pre").first();
await expect(pre).toBeVisible({ timeout: 5_000 });
await expect(pre).toContainText("const x = 1;");
});
test("table renders <table>", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("| A | B |\n|---|---|\n| 1 | 2 |");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: | A | B |")).toBeVisible({ timeout: 15_000 });
const table = page.locator("table").first();
await expect(table).toBeVisible({ timeout: 5_000 });
await expect(table).toContainText("A");
await expect(table).toContainText("1");
});
});
-97
View File
@@ -1,97 +0,0 @@
import { test, expect } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
test.describe("MobileChat", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
// Navigate directly to the mobile chat view.
await page.goto(`/?m=chat&a=${workspaceId}`);
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
// Dismiss onboarding guide if present.
const skipGuide = page.getByText("Skip guide");
if (await skipGuide.isVisible().catch(() => false)) {
await skipGuide.click();
}
});
test("chat panel loads without error", async ({ page }) => {
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
expect(hasEmptyState || hasHistory).toBeTruthy();
});
test("send text message and receive echo response", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile test message");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Mobile test message")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Mobile test message")).toBeVisible({ timeout: 15_000 });
});
test("history persists across reload", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile persistence");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
await expect(page.getByText("Mobile persistence", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 5_000 });
});
test("composer auto-grows with multi-line text", async ({ page }) => {
const textarea = page.locator("textarea").first();
const initialHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
await textarea.fill("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
await page.waitForTimeout(300);
const grownHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
expect(grownHeight).toBeGreaterThan(initialHeight);
});
test("file attachment in mobile chat", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile file test");
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
await fileInput.setInputFiles({
name: "mobile.txt",
mimeType: "text/plain",
buffer: Buffer.from("mobile secret"),
});
await expect(page.getByText("mobile.txt")).toBeVisible({ timeout: 3_000 });
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Mobile file test")).toBeVisible({ timeout: 15_000 });
});
});
-187
View File
@@ -1,187 +0,0 @@
/**
* E2E seed fixture for chat tests.
*
* Creates an external workspace via the workspace-server API, extracts the
* auto-minted auth token, then overrides the DB row so it appears "online"
* with an echo-runtime URL. External runtime is used because the health
* sweep skips Docker checks for external workspaces; we keep the workspace
* alive with periodic heartbeats.
*/
import { randomUUID } from "node:crypto";
const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
export interface SeededWorkspace {
id: string;
name: string;
agentURL: string;
authToken: string;
}
/**
* Create an external workspace and wire it to the echo runtime.
*/
export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
// 1. Create external workspace (no URL — platform will mint an auth token).
const runId = Math.random().toString(36).slice(2, 8);
const wsName = `Chat E2E Agent ${runId}`;
const createRes = await fetch(`${PLATFORM_URL}/workspaces`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: wsName, tier: 1, external: true, runtime: "external" }),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new Error(`Failed to create workspace: ${createRes.status} ${text}`);
}
const ws = (await createRes.json()) as {
id: string;
name: string;
connection?: { auth_token?: string };
};
const authToken = ws.connection?.auth_token;
if (!authToken) {
throw new Error("Workspace created but no auth_token returned");
}
// 2. Direct DB update: mark online + point url at echo runtime.
// The platform blocks loopback URLs at the API layer (SSRF guard),
// so we bypass via psql for local E2E.
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) {
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
}
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) {
throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`);
}
const [, user, pass, host, port, db] = m;
// Pre-seed a platform_inbound_secret so chat file uploads don't trigger
// the lazy-heal 503 "retry in 30 s" path on first use.
const inboundSecret = Array.from({ length: 43 }, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[
Math.floor(Math.random() * 64)
],
).join("");
const psql = [
`PGPASSWORD=${pass} psql`,
`-h ${host} -p ${port} -U ${user} -d ${db}`,
`-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`,
].join(" ");
const { execSync } = await import("node:child_process");
try {
execSync(psql, { stdio: "pipe", timeout: 30_000 });
} catch (err) {
throw new Error(`DB update failed: ${err}`);
}
return { id: ws.id, name: wsName, agentURL: echoURL, authToken };
}
/**
* Start a heartbeat interval that keeps an external workspace alive.
* Returns a stop function.
*/
export function startHeartbeat(
workspaceId: string,
authToken: string,
intervalMs = 30_000,
): () => void {
const send = () => {
fetch(`${PLATFORM_URL}/registry/heartbeat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
workspace_id: workspaceId,
error_rate: 0,
sample_error: "",
active_tasks: 0,
current_task: "",
uptime_seconds: 0,
}),
}).catch(() => {});
};
// Send immediately so the first heartbeat lands before the stale sweep.
send();
const timer = setInterval(send, intervalMs);
return () => clearInterval(timer);
}
/**
* Seed chat-history rows for a workspace.
*/
export async function seedChatHistory(
workspaceId: string,
messages: Array<{ role: "user" | "agent"; content: string }>,
): Promise<void> {
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) return;
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) return;
const [, user, pass, host, port, db] = m;
const values = messages
.map(
(msg, i) =>
`('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`,
)
.join(",");
const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`;
const { execSync } = await import("node:child_process");
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`;
execSync(psql, { stdio: "pipe", timeout: 10_000 });
}
/**
* Delete a seeded workspace row directly from the DB.
* Uses psql (same credentials as seedWorkspace) so we bypass any
* workspace-server side-effects (container stop, cascade cleanup, etc.)
* that can race or 500 on external workspaces.
*/
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) return;
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) return;
const [, user, pass, host, port, db] = m;
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`;
const { execSync } = await import("node:child_process");
try {
execSync(psql, { stdio: "pipe", timeout: 30_000 });
} catch {
// Best-effort cleanup; don't fail the test suite if the row is already gone.
}
}
/**
* Mint a workspace auth token so the canvas can make authenticated API
* calls (WorkspaceAuth middleware).
*/
export async function mintTestToken(workspaceId: string): Promise<string> {
const res = await fetch(
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
);
if (!res.ok) {
throw new Error(`Failed to mint test token: ${res.status}`);
}
const data = (await res.json()) as { auth_token: string };
return data.auth_token;
}
-180
View File
@@ -1,180 +0,0 @@
/**
* Minimal A2A echo runtime for E2E tests.
*
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
* requests, and returns a response with the original text echoed back.
* Also implements the workspace-side chat upload ingest endpoint so
* file-attachment E2E can exercise the full upload → send → echo
* round-trip.
*
* Usage (inside test fixture):
* const echo = await startEchoRuntime();
* // ... seed workspace with agent_url pointing to echo.baseURL ...
* echo.stop();
*/
import { createServer, type Server } from "node:http";
export interface EchoRuntime {
baseURL: string;
stop: () => Promise<void>;
lastRequest: { method: string; text: string; files: unknown[] } | null;
}
/** Parse a minimal multipart body and extract the first file's name + content. */
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
// Find the boundary line (first line starting with "--").
const str = body.toString("binary");
const firstDash = str.indexOf("--");
if (firstDash === -1) return null;
const eol = str.indexOf("\r\n", firstDash);
if (eol === -1) return null;
const boundary = str.slice(firstDash + 2, eol);
const boundaryMarker = "\r\n--" + boundary;
// Find the first part that has a filename in Content-Disposition.
let pos = eol + 2;
while (pos < str.length) {
const nextBoundary = str.indexOf(boundaryMarker, pos);
if (nextBoundary === -1) break;
const part = str.slice(pos, nextBoundary);
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
if (cdMatch) {
const name = cdMatch[1];
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
// Body starts after the first double-CRLF in the part.
const bodyStart = part.indexOf("\r\n\r\n");
if (bodyStart !== -1) {
// Extract the raw bytes (not the string) so binary is safe.
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
const content = body.subarray(partStartInBody, partEndInBody);
return { name, mimeType, content };
}
}
pos = nextBoundary + boundaryMarker.length;
// Skip trailing "--" (end marker) or CRLF.
if (str.slice(pos, pos + 2) === "--") break;
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
}
return null;
}
export async function startEchoRuntime(): Promise<EchoRuntime> {
let lastRequest: EchoRuntime["lastRequest"] = null;
const server = createServer((req, res) => {
// CORS: allow the canvas origin (localhost:3000) to call us.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = req.url ?? "/";
// Workspace-side chat upload ingest (RFC #2312).
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks);
const file = parseMultipart(body);
if (!file) {
res.writeHead(400);
res.end(JSON.stringify({ error: "no files field" }));
return;
}
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
const prefix = Array.from({ length: 32 }, () =>
Math.floor(Math.random() * 16).toString(16),
).join("");
const response = {
files: [
{
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
name: sanitized,
mimeType: file.mimeType,
size: file.content.length,
},
],
};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(JSON.stringify(response));
});
return;
}
// Default: A2A JSON-RPC handler.
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
body += chunk;
});
req.on("end", () => {
res.setHeader("Content-Type", "application/json");
try {
const rpc = JSON.parse(body);
const msg = rpc.params?.message;
const textParts =
msg?.parts
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
.map((p: { text?: string }) => p.text)
.filter(Boolean) ?? [];
const fileParts =
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
const text = textParts.join("\n");
lastRequest = {
method: rpc.method ?? "unknown",
text,
files: fileParts,
};
const replyText = text
? `Echo: ${text}`
: fileParts.length > 0
? "Echo: received your file(s)."
: "Echo: hello";
const response = {
jsonrpc: "2.0",
id: rpc.id ?? null,
result: {
parts: [{ kind: "text", text: replyText }],
},
};
res.writeHead(200);
res.end(JSON.stringify(response));
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "invalid json" }));
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
const baseURL = `http://127.0.0.1:${port}`;
return {
baseURL,
stop: () =>
new Promise((resolve) => {
server.close(() => resolve(undefined));
}),
get lastRequest() {
return lastRequest;
},
};
}
-1
View File
@@ -5,7 +5,6 @@ export default defineConfig({
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
retries: 0,
use: {
baseURL: "http://localhost:3000",
@@ -11,21 +11,13 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestConnectionButton } from "../ui/TestConnectionButton";
import type { SecretGroup } from "@/types/secrets";
import { validateSecret, ApiError } from "@/lib/api/secrets";
import { validateSecret } from "@/lib/api/secrets";
// ─── Mock validateSecret ──────────────────────────────────────────────────────
// vi.mock is hoisted, so validateSecret (imported above) refers to the mocked
// namespace value once vi.mock runs. Use vi.mocked() to access it in tests.
vi.mock("@/lib/api/secrets", () => ({
validateSecret: vi.fn(),
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
},
}));
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
@@ -110,7 +102,7 @@ describe("TestConnectionButton — state machine", () => {
expect(screen.getByText("Permission denied")).toBeTruthy();
});
it("shows a connectivity message on a genuine network exception", async () => {
it("shows generic error message on unexpected exception", async () => {
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
@@ -118,23 +110,8 @@ describe("TestConnectionButton — state machine", () => {
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
// A real thrown network error → honest connectivity message (not a
// fabricated "service down"); see internal#492.
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(
/could not reach the validation service/i,
);
});
it("does not claim a timeout when the validate endpoint 404s (internal#492)", async () => {
vi.mocked(validateSecret).mockRejectedValue(new ApiError(404, "Not Found"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
const alert = document.body.querySelector('[role="alert"]')?.textContent ?? "";
expect(alert).not.toMatch(/timed out/i);
expect(alert).toMatch(/not available/i);
// The error detail is hardcoded to "Connection timed out. Service may be down."
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/timed out/i);
});
});
+202 -318
View File
@@ -6,21 +6,21 @@
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
import {
useChatHistory,
useChatSend,
useChatSocket,
} from "@/components/tabs/chat/hooks";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
text: string;
ts: string;
}
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
@@ -29,171 +29,30 @@ const formatStoredTimestamp = (iso: string): string => {
type SubTab = "my" | "a2a";
function MarkdownBubble({
children,
dark,
accent,
}: {
children: string;
dark: boolean;
accent: string;
}) {
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
const linkColor = accent;
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: linkColor, textDecoration: "underline" }}
>
{children}
</a>
),
pre: ({ children }) => (
<pre
style={{
background: codeBlockBg,
padding: "8px 10px",
borderRadius: 8,
overflow: "auto",
fontSize: 12,
lineHeight: 1.5,
fontFamily: MOBILE_FONT_MONO,
margin: "4px 0",
}}
>
{children}
</pre>
),
code: ({ children, className }) => {
const isBlock = className != null && String(className).length > 0;
if (isBlock) {
return (
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
{children}
</code>
);
}
return (
<code
style={{
background: codeBg,
padding: "1px 4px",
borderRadius: 4,
fontSize: 13,
fontFamily: MOBILE_FONT_MONO,
}}
>
{children}
</code>
);
},
ul: ({ children }) => (
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
{children}
</ol>
),
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
strong: ({ children }) => (
<strong style={{ fontWeight: 600 }}>{children}</strong>
),
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
h1: ({ children }) => (
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h2: ({ children }) => (
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h3: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h4: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h5: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h6: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
blockquote: ({ children }) => (
<blockquote
style={{
borderLeft: `2px solid ${quoteBorder}`,
margin: "4px 0",
paddingLeft: 8,
opacity: 0.85,
}}
>
{children}
</blockquote>
),
hr: () => (
<hr
style={{
border: "none",
borderTop: `0.5px solid ${quoteBorder}`,
margin: "6px 0",
}}
/>
),
table: ({ children }) => (
<table
style={{
borderCollapse: "collapse",
fontSize: 13,
margin: "4px 0",
width: "100%",
}}
>
{children}
</table>
),
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
th: ({ children }) => (
<th
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
textAlign: "left",
}}
>
{children}
</th>
),
td: ({ children }) => (
<td
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
}}
>
{children}
</td>
),
}}
>
{children}
</ReactMarkdown>
);
interface A2AResponseShape {
result?: {
parts?: Array<{ kind?: string; text?: string }>;
};
error?: { message?: string };
}
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
interface ApiChatMessage {
id: string;
role: string; // "user" | "agent" | "system"
content: string;
timestamp: string;
attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
}
interface ChatHistoryResponse {
messages: ApiChatMessage[];
reached_end: boolean;
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
export function MobileChat({
agentId,
dark,
@@ -204,40 +63,36 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
// that creates a new [] reference on every store update when the key is
// absent, causing infinite re-render (React error #185).
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
// Start empty — history is loaded via useEffect below.
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // history is loading on mount
const [historyError, setHistoryError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const {
messages,
loading: historyLoading,
loadError: historyError,
loadInitial,
appendMessageDeduped,
} = useChatHistory(agentId);
const {
sending,
uploading,
sendMessage,
error: sendError,
clearError,
releaseSendGuards,
} = useChatSend(agentId, {
getHistoryMessages: () => messages,
onUserMessage: appendMessageDeduped,
onAgentMessage: appendMessageDeduped,
});
useChatSocket(agentId, {
onAgentMessage: appendMessageDeduped,
onSendComplete: releaseSendGuards,
});
// Guard: don't treat the initial store population as a live push.
// Set to false after the first render completes.
const initDoneRef = useRef(false);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -250,26 +105,81 @@ export function MobileChat({
el.style.height = `${next}px`;
}, [draft]);
// Fetch chat history on mount; keep merging live agentMessages while the
// panel is open. InitDoneRef prevents the initial store snapshot from
// triggering the live-merge path (the store buffer is populated by
// ChatTab on desktop, not on mobile — this effect loads history as the
// mobile-native path).
useEffect(() => {
let cancelled = false;
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
});
const syncLive = () => {
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
if (live.length > 0) {
setMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.id));
const newOnes = live
.filter((m) => !existingIds.has(m.id))
.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
}));
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
});
}
};
const bootstrap = async (): Promise<(() => void) | undefined> => {
setLoading(true);
setHistoryError(null);
try {
const res = await api.get<ChatHistoryResponse>(
`/workspaces/${agentId}/chat-history?limit=50`,
);
if (cancelled) return;
const initial = (res.messages ?? []).map(mapApiMessage);
setMessages(initial);
// Mark init done BEFORE marking loading=false so any store push
// that arrives in the same tick is treated as live, not init.
initDoneRef.current = true;
setLoading(false);
// Subscribe to live pushes after init is complete.
syncLive();
const unsubscribe = useCanvasStore.subscribe(syncLive);
return unsubscribe; // returned for cleanup
} catch (e) {
if (cancelled) return;
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
setLoading(false);
initDoneRef.current = true;
return undefined;
}
};
let maybeUnsubscribe: (() => void) | undefined;
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
return () => {
cancelled = true;
if (maybeUnsubscribe) maybeUnsubscribe();
};
}, [agentId]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
// Consume any agent messages that arrived while history was loading.
const initialConsumeDoneRef = useRef(false);
useEffect(() => {
if (historyLoading || initialConsumeDoneRef.current) return;
initialConsumeDoneRef.current = true;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
for (const m of msgs) {
appendMessageDeduped(
createMessage("agent", m.content, m.attachments),
);
}
}, [historyLoading, agentId, appendMessageDeduped]);
if (!node) {
return (
<div
@@ -291,32 +201,58 @@ export function MobileChat({
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
});
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
const send = async () => {
const text = draft.trim();
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
clearError();
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
const files = pendingFiles;
setPendingFiles([]);
await sendMessage(text, files);
setError(null);
setSending(true);
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
};
setMessages((m) => [...m, myMsg]);
try {
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts: [{ kind: "text", text }],
},
},
});
const reply =
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
if (reply) {
setMessages((m) => [
...m,
{
id: crypto.randomUUID(),
role: "agent",
text: reply,
ts: formatTime(new Date()),
},
]);
} else if (res.error?.message) {
setError(res.error.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send");
} finally {
setSending(false);
sendInFlightRef.current = false;
}
};
return (
<div
data-testid="chat-panel"
style={{
height: "100%",
display: "flex",
@@ -457,12 +393,13 @@ export function MobileChat({
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && historyLoading && (
{tab === "my" && loading && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Loading chat history
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}></div>
<div>Loading chat history</div>
</div>
)}
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
{tab === "my" && !loading && historyError && (
<div
role="alert"
style={{
@@ -476,7 +413,25 @@ export function MobileChat({
<button
type="button"
onClick={() => {
loadInitial();
setLoading(true);
setHistoryError(null);
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
(res: unknown) => {
const r = res as ChatHistoryResponse;
setMessages((r.messages ?? []).map((m) => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})));
setLoading(false);
initDoneRef.current = true;
},
).catch((e: unknown) => {
setHistoryError(e instanceof Error ? e.message : "Failed to load");
setLoading(false);
initDoneRef.current = true;
});
}}
style={{
padding: "6px 14px",
@@ -492,7 +447,7 @@ export function MobileChat({
</button>
</div>
)}
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
{tab === "my" && !loading && !historyError && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
@@ -521,9 +476,7 @@ export function MobileChat({
overflowWrap: "anywhere",
}}
>
<MarkdownBubble dark={dark} accent={p.accent}>
{m.content}
</MarkdownBubble>
{m.text}
<div
style={{
fontSize: 10,
@@ -532,13 +485,13 @@ export function MobileChat({
fontFamily: MOBILE_FONT_MONO,
}}
>
{formatStoredTimestamp(m.timestamp)}
{m.ts}
</div>
</div>
</div>
);
})}
{sendError && (
{error && (
<div
role="alert"
style={{
@@ -550,7 +503,7 @@ export function MobileChat({
fontSize: 12,
}}
>
{sendError}
{error}
</div>
)}
</div>
@@ -581,60 +534,6 @@ export function MobileChat({
backdropFilter: "blur(14px)",
}}
>
{pendingFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
paddingLeft: 2,
}}
>
{pendingFiles.map((f, i) => (
<div
key={`${f.name}:${f.size}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 10,
background: dark ? "#2a2823" : "#ece9e0",
fontSize: 12,
color: p.text2,
maxWidth: "100%",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{f.name}
</span>
<button
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
style={{
border: "none",
background: "transparent",
color: p.text3,
cursor: "pointer",
fontSize: 12,
padding: 0,
lineHeight: 1,
}}
>
</button>
</div>
))}
</div>
)}
<div
style={{
display: "flex",
@@ -646,32 +545,21 @@ export function MobileChat({
padding: "6px 6px 6px 12px",
}}
>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => onFilesPicked(e.target.files)}
aria-hidden="true"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: !reachable || sending || uploading ? 0.4 : 1,
}}
>
{Icons.attach({ size: 16 })}
@@ -717,32 +605,28 @@ export function MobileChat({
<button
type="button"
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{uploading ? (
<span style={{ fontSize: 10, fontWeight: 600 }}></span>
) : (
Icons.send({ size: 16 })
)}
{Icons.send({ size: 16 })}
</button>
</div>
</div>
@@ -214,7 +214,6 @@ export function MobileDetail({
<button
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
style={{
width: "100%",
height: 52,
@@ -36,7 +36,6 @@ const mockStoreState = {
height?: number;
}>,
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
consumeAgentMessages: () => [],
};
vi.mock("@/store/canvas", () => ({
@@ -358,7 +357,7 @@ describe("MobileChat — chat history", () => {
renderChat(mockAgentId);
});
expect(api.get).toHaveBeenCalledWith(
expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
`/workspaces/${mockAgentId}/chat-history?limit=50`,
);
});
@@ -288,7 +288,6 @@ export function AgentCard({
return (
<button
type="button"
data-testid="workspace-card"
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
onClick={onClick}
style={{
+23 -19
View File
@@ -3,24 +3,16 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { Secret, SecretGroup } from '@/types/secrets';
import { useSecretsStore } from '@/stores/secrets-store';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { RevealToggle } from '@/components/ui/RevealToggle';
import { KeyValueField } from '@/components/ui/KeyValueField';
import { ValidationHint } from '@/components/ui/ValidationHint';
import { TestConnectionButton } from '@/components/ui/TestConnectionButton';
import { validateSecretValue } from '@/lib/validation/secret-formats';
import { SERVICES } from '@/lib/services';
const AUTO_HIDE_MS = 30_000;
const VALIDATION_DEBOUNCE_MS = 400;
// Secret values are write-only from the browser: the server List endpoint
// "Never exposes values", there is no per-secret decrypt route, and the
// only decrypted path (GET /secrets/values) is bulk + token-gated for
// remote agents. The old eye/RevealToggle was a dead affordance — it
// flipped its own icon but could never reveal anything, which read as
// "this doesn't work" (esp. once clicked → eye-with-slash). We show an
// honest static indicator instead; rotation is via Edit.
const WRITE_ONLY_TITLE =
'Value is write-only and cannot be revealed — use Edit to replace/rotate it';
interface SecretRowProps {
secret: Secret;
workspaceId: string;
@@ -39,12 +31,28 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
const setSecretStatus = useSecretsStore((s) => s.setSecretStatus);
const isEditing = editingKey === secret.name;
const [revealed, setRevealed] = useState(false);
const [editValue, setEditValue] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const editBtnRef = useRef<HTMLButtonElement>(null);
const revealTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Auto-hide revealed value after 30s
useEffect(() => {
if (revealed) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS);
return () => clearTimeout(revealTimerRef.current);
}
}, [revealed]);
// Reset revealed state when panel closes (session-only)
useEffect(() => {
return () => setRevealed(false);
}, []);
// Debounced validation
useEffect(() => {
@@ -125,15 +133,11 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
{secret.masked_value}
</span>
<div className="secret-row__actions">
<span
data-testid="write-only-indicator"
className="secret-row__write-only"
role="img"
aria-label={`${secret.name} value is write-only and cannot be revealed; use Edit to replace it`}
title={WRITE_ONLY_TITLE}
>
🔒
</span>
<RevealToggle
revealed={revealed}
onToggle={() => setRevealed((r) => !r)}
label={`Toggle reveal ${secret.name}`}
/>
<StatusBadge status={secret.status} />
<button
type="button"
@@ -16,40 +16,7 @@ interface TokensTabProps {
workspaceId: string;
}
// The settings panel passes the literal sentinel "global" when no canvas
// node is selected. Workspace tokens are inherently per-workspace — there
// is no /workspaces/global/tokens endpoint (querying the uuid column with
// "global" 500s on Postgres). The org-wide equivalent lives in the
// separate "Org API Keys" tab. Mirrors the sentinel-awareness that
// api/secrets.ts already has (workspaceId === 'global' → /settings/secrets).
const GLOBAL_WORKSPACE_ID = 'global';
export function TokensTab({ workspaceId }: TokensTabProps) {
if (workspaceId === GLOBAL_WORKSPACE_ID) {
return (
<div className="p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
<p className="text-[10px] text-ink-mid mt-0.5">
Bearer tokens for authenticating API calls to this workspace.
</p>
</div>
<div className="text-center py-6">
<p className="text-xs text-ink-mid">Select a workspace node first</p>
<p className="text-[10px] text-ink-mid mt-1">
Workspace tokens are scoped to a single workspace. Select a node
on the canvas to manage its tokens, or use the{' '}
<span className="text-accent font-medium">Org API Keys</span> tab
for org-wide API keys.
</p>
</div>
</div>
);
}
return <WorkspaceTokensTab workspaceId={workspaceId} />;
}
function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
const [tokens, setTokens] = useState<Token[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -138,54 +138,14 @@ describe("SecretRow — display mode", () => {
expect(document.querySelector('[role="row"]')).toBeTruthy();
});
it("has Copy, Edit, Delete buttons", () => {
it("has Reveal, Copy, Edit, Delete buttons", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("reveal-toggle")).toBeTruthy();
expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
});
// Regression: the reveal/eye control was a dead affordance. Clicking it
// flipped its own icon (eye → eye-with-slash) but never revealed the value,
// because secret values are write-only from the browser (server List
// "Never exposes values"; there is no per-secret decrypt endpoint and the
// client has no plaintext-fetch function). The honest fix removes the
// toggle and shows a static "write-only / cannot be revealed" indicator.
// See internal tracking issue + internal#210/#211.
it("does NOT render a reveal/eye toggle (values are write-only)", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.queryByTestId("reveal-toggle")).toBeNull();
expect(
screen.queryByRole("button", { name: /toggle reveal/i }),
).toBeNull();
});
it("shows a write-only indicator explaining the value cannot be revealed", () => {
render(<SecretRow secret={ANTHROPIC_SECRET} workspaceId="ws-1" />);
const indicator = screen.getByTestId("write-only-indicator");
expect(indicator).toBeTruthy();
// Affordance must be honest: explain it cannot be revealed and that
// Edit is the rotate path. It must not be a clickable button.
const title = indicator.getAttribute("title") ?? "";
expect(title.toLowerCase()).toMatch(/write-only|cannot be revealed/);
expect(indicator.tagName).not.toBe("BUTTON");
});
it("write-only indicator is present for the Anthropic/OAuth-token row too", () => {
// The reported bug singled out CLAUDE_CODE_OAUTH_TOKEN (anthropic group);
// the fix is group-agnostic — every row gets the same honest affordance.
const OAUTH_SECRET = {
name: "CLAUDE_CODE_OAUTH_TOKEN",
masked_value: "••••••••••••••••9d2a",
group: "anthropic" as const,
status: "unverified" as const,
updated_at: "2024-01-04",
};
render(<SecretRow secret={OAUTH_SECRET} workspaceId="ws-1" />);
expect(screen.queryByTestId("reveal-toggle")).toBeNull();
expect(screen.getByTestId("write-only-indicator")).toBeTruthy();
});
it("shows invalid status correctly", () => {
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("invalid");
@@ -302,35 +302,3 @@ describe("TokensTab — error", () => {
expect(document.querySelector('[role="status"]')).toBeNull();
});
});
// ─── "global" sentinel (no node selected) ────────────────────────────────────
//
// Regression: SettingsPanel passes the literal "global" when no canvas
// node is selected. workspace tokens are per-workspace and there is no
// /workspaces/global/tokens endpoint — calling it 500'd
// ("invalid input syntax for type uuid: global"). The tab must NOT call
// the API in that state and must point the user at the Org API Keys tab.
describe("TokensTab — global sentinel (no node selected)", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockApiPost.mockReset();
mockApiGet.mockRejectedValue(new Error("should not be called"));
});
it("does not call the API and shows a pointer to Org API Keys", async () => {
render(<TokensTab workspaceId="global" />);
await flush();
expect(mockApiGet).not.toHaveBeenCalled();
expect(mockApiPost).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Select a workspace node");
expect(document.body.textContent).toContain("Org API Keys");
// No error banner, no scary 500 surfacing.
expect(document.querySelector(".text-bad")).toBeNull();
});
it("has no create button in the global state", async () => {
render(<TokensTab workspaceId="global" />);
await flush();
expect(document.body.textContent).not.toContain("New Token");
});
});
+696 -97
View File
@@ -5,19 +5,16 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { PendingAttachmentPill } from "./chat/AttachmentViews";
import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useChatHistory } from "./chat/hooks/useChatHistory";
import { useChatSend } from "./chat/hooks/useChatSend";
import { useChatSocket } from "./chat/hooks/useChatSocket";
export { extractReplyText } from "./chat/hooks/useChatSend";
interface Props {
workspaceId: string;
@@ -26,6 +23,147 @@ interface Props {
type ChatSubTab = "my-chat" | "agent-comms";
// A2A response shape (subset). The full schema is in @a2a-js/sdk but we only
// need parts/artifacts text + file extraction for the synchronous fallback.
interface A2AFileRef {
name?: string;
mimeType?: string;
uri?: string;
bytes?: string;
size?: number;
}
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
// Pydantic union (TextPart | FilePart | DataPart). The flat
// protobuf shape `{url, filename, mediaType}` is rejected at the
// request boundary with `Field required` errors — keep this
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
interface A2APart {
kind: string;
text?: string;
file?: A2AFileRef;
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
// Internal-self-message filtering moved server-side in RFC #2945
// PR-C/D — the platform's /chat-history endpoint applies the
// IsInternalSelfMessage predicate before returning rows, so the
// client no longer needs the local backstop on the history path.
// The proper fix is still X-Workspace-ID header (source_id=workspace_id);
// the platform-side prefix filter handles the residual cases.
// extractReplyText pulls the agent's text reply out of an A2A response.
// Concatenates ALL text parts (joined with "\n") rather than returning
// just the first. Claude Code and other runtimes commonly emit multi-
// part text replies for long content (markdown tables, code blocks),
// and the prior "first part wins" implementation silently truncated
// the rest — observed on a 15k-char Wave 1 brief that rendered only
// the table header. Mirrors extractTextsFromParts in message-parser.ts.
//
// Server-side counterpart in workspace-server/internal/channels/
// manager.go has the same single-part bug; fix that too if/when a
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
// Walk artifacts even if parts had text — some producers (Hermes
// tool calls) emit a summary in parts AND details in artifacts.
// Returning early on parts dropped the artifact body silently.
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
// Agent-returned files live on the same response shape as text —
// delegated to extractFilesFromTask in message-parser.ts, which also
// walks status.message.parts (that ChatTab's legacy text extractor
// doesn't). Single source of truth for file-part parsing across
// live chat, activity log replay, and any future consumers.
/** Initial chat history page size. The newest N messages are rendered
* on first paint; older history is fetched on demand via loadOlder()
* when the user scrolls the top sentinel into view. */
const INITIAL_HISTORY_LIMIT = 10;
/** Subsequent older-history batch size. Larger than INITIAL so a long
* scroll-back doesn't fan out into many round-trips. */
const OLDER_HISTORY_BATCH = 20;
/**
* Load chat history from the platform's typed /chat-history endpoint.
*
* Server-side rendering of activity_logs rows into ChatMessage shape
* lives in workspace-server/internal/messagestore/postgres_store.go
* (RFC #2945 PR-C/D). The server already applies the canvas-source
* filter, the internal-self-message predicate, the role decision
* (status=error vs agent-error prefix → system), and the v0/v1
* file-shape extraction. Canvas just renders what it receives.
*
* Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
*
* GET /workspaces/:id/chat-history?limit=N&before_ts=T
* 200 → {"messages": ChatMessage[], "reached_end": boolean}
*
* Pagination:
* - Pass `limit` to bound the page size (newest-first from server).
* - Pass `beforeTs` (RFC3339) to fetch rows STRICTLY OLDER than that
* timestamp. Combined with limit, this yields the next-older page
* when scrolling backward through history.
*
* `reachedEnd` is propagated from the server. The server computes it
* by comparing rowCount vs limit so a partial last page is correctly
* detected even when the row→bubble fan-out is non-1:1 (each row
* produces 1-2 bubbles).
*/
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
// Server emits oldest-first within the page (RFC #2945 PR-C-2
// post-fix: server reverses row-aware before returning so the
// wire is display-ready). Canvas appends/prepends without
// reordering — this avoids the pair-flip bug a naive flat
// reverse causes when each row produces a (user, agent) pair
// with the same timestamp.
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
/**
* ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel.
*/
@@ -33,7 +171,7 @@ export function ChatTab({ workspaceId, data }: Props) {
const [subTab, setSubTab] = useState<ChatSubTab>("my-chat");
return (
<div data-testid="chat-panel" className="flex flex-col h-full">
<div className="flex flex-col h-full">
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
<div
role="tablist"
@@ -109,68 +247,268 @@ export function ChatTab({ workspaceId, data }: Props) {
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
*/
function MyChatPanel({ workspaceId, data }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [activityLog, setActivityLog] = useState<string[]>([]);
// `sending` is strictly the "this tab kicked off a send and hasn't
// seen the reply yet" signal. Previously this was initialized from
// data.currentTask to pick up in-flight agent work on mount, but
// that conflated agent-busy (workspace heartbeat) with user-
// in-flight (local send): when the WS dropped a TASK_COMPLETE event,
// currentTask lingered, the component re-mounted with sending=true,
// and the Send button stayed disabled forever even though nothing
// local was in flight. For the "agent is busy, show spinner" UX,
// use data.currentTask directly in the render path.
const [sending, setSending] = useState(false);
const [thinkingElapsed, setThinkingElapsed] = useState(0);
const [activityLog, setActivityLog] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const currentTaskRef = useRef(data.currentTask);
const sendingFromAPIRef = useRef(false);
const [agentReachable, setAgentReachable] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmRestart, setConfirmRestart] = useState(false);
const [dragOver, setDragOver] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// First-mount scroll-to-bottom needs `behavior: "instant"` — long
// conversations smooth-animate for ~300ms which any concurrent
// re-render can interrupt, leaving the user stuck mid-conversation
// when the chat tab opens. Subsequent appends (new agent messages)
// keep `smooth` for the visual "landing" feel. Flipped the first
// time messages.length goes positive, so a workspace switch (which
// remounts ChatTab) gets a fresh instant jump too.
const hasInitialScrollRef = useRef(false);
// Lazy-load older history on scroll-up.
// - containerRef = the scrollable messages viewport
// - topRef = sentinel above the messages list; IO observes it
// and triggers loadOlder() when it enters view
// - hasMore = false once a fetch returns < limit rows; stops IO
// - loadingOlder = drives the "Loading older messages…" UI label
// - inflightRef = synchronous guard against double-entry of loadOlder
// when the IO callback fires twice in the same
// microtask (state-based guard would be stale until
// the next React commit)
// - scrollAnchorRef = saves distance-from-bottom before a prepend
// so the useLayoutEffect below can restore the
// user's exact viewport position. Without this,
// prepending older messages would jump the scroll
// position by the height of the new content.
// - oldestMessageRef / hasMoreRef = let the loadOlder closure read
// the latest values without taking them as deps —
// every live agent push mutates `messages`, and
// having loadOlder depend on `messages` would tear
// down + re-arm the IntersectionObserver on every
// push. Refs decouple the observer lifecycle from
// message-list updates.
const containerRef = useRef<HTMLDivElement>(null);
const topRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const hasInitialScrollRef = useRef(false);
const [hasMore, setHasMore] = useState(true);
const [loadingOlder, setLoadingOlder] = useState(false);
const inflightRef = useRef(false);
// The scroll anchor includes the first-message id as it was BEFORE
// the prepend — see useLayoutEffect below for why. Without this tag,
// a live agent push that appends WHILE loadOlder is in flight would
// run useLayoutEffect against the append (anchor still set), the
// "restore" math would scroll the user to a stale offset, AND the
// append's normal scroll-to-bottom would be swallowed.
const scrollAnchorRef = useRef<
{ savedDistanceFromBottom: number; expectFirstIdNotEqual: string | null } | null
>(null);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
// Monotonic token bumped on workspace switch + on every loadOlder
// entry. Each fetch's .then() captures its own token; if the token
// has moved, the resolved messages belong to a stale workspace or a
// superseded fetch and we silently drop them. Without this guard, a
// workspace switch mid-fetch would have the in-flight promise
// resolve into the new workspace's setMessages — the user sees
// someone else's history briefly.
const fetchTokenRef = useRef(0);
// Files the user has picked but not yet sent. Cleared on send
// (upload success) or by the × on each pill.
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pasteCounterRef = useRef(0);
// Guard against a double-click during the upload phase: React
// state updates from the click that started the upload haven't
// flushed yet, so the disabled-button logic sees `uploading=false`
// from the closure and lets a second `sendMessage` enter. A ref
// observes the latest value synchronously.
const sendInFlightRef = useRef(false);
// Monotonic token bumped on every sendMessage entry. Each .then()/
// .catch() captures its own token in closure and bails if a newer
// send has superseded it — prevents a late HTTP response for an
// earlier message from clobbering the flags / appending text that
// belong to a newer in-flight send. Race scenario the token closes:
// (1) send msg #1 (2) WS push for msg #1 arrives, releases guards
// (3) user sends msg #2 (4) HTTP for msg #1 finally lands — without
// the token check, .then() sees sendingFromAPIRef=true (set by
// msg #2's send), enters the main body, and processes msg #1's body
// as if it were msg #2's reply.
const sendTokenRef = useRef(0);
const history = useChatHistory(workspaceId, containerRef);
const chatSend = useChatSend(workspaceId, {
getHistoryMessages: () => history.messages,
onUserMessage: (msg) => history.setMessages((prev) => [...prev, msg]),
onAgentMessage: (msg) => history.setMessages((prev) => appendMessageDeduped(prev, msg)),
});
const { sending, uploading, sendMessage, error: sendError, clearError: clearSendError, releaseSendGuards, sendingFromAPIRef } = chatSend;
// Release every in-flight send guard at once. Used by every site
// that ends a send: pendingAgentMsgs WS push, ACTIVITY_LOGGED
// a2a_receive ok/error WS event, HTTP .then() success, and HTTP
// .catch() success. Keep these in lockstep — a future contributor
// adding a new "I saw the reply" path that only clears `sending` +
// `sendingFromAPIRef` (the natural pair) silently re-introduces
// the post-WS Send-button freeze, because the disabled-button
// logic can't see `sendInFlightRef` and so the visible state diverges
// from the synchronous re-entry guard at line 464.
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const displayError = error || sendError;
// Initial-load fetch — used by the mount effect and the "Retry"
// button below. Single source of truth so the two paths can't drift
// (e.g. INITIAL_HISTORY_LIMIT bumped in the effect but not the
// retry, leading to inconsistent first-paint sizes).
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
// Bump the token; any in-flight fetch from the previous workspace
// (or a previous retry) will see token != myToken in its .then()
// and silently bail — the late response can't clobber the new
// workspace's state.
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useChatSocket(workspaceId, {
onAgentMessage: (msg) => {
history.setMessages((prev) => appendMessageDeduped(prev, msg));
if (sendingFromAPIRef.current) {
releaseSendGuards();
// Load chat history on mount / workspace switch.
// Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
// rest streams in as the user scrolls up via loadOlder() below. Pre-
// 2026-05-05 this fetched the newest 50 in one shot; on a long-running
// workspace that meant 50× message-bubble paint + DOM cost on every
// tab-open even when the user only wanted to read the last few.
useEffect(() => {
loadInitial();
}, [loadInitial]);
// Mirror the latest oldest-message + hasMore into refs so loadOlder
// can read them without taking `messages` as a dep. Every live push
// through agentMessages would otherwise recreate loadOlder and tear
// down the IO observer.
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
// Fetch the next-older batch and prepend. Stable identity (deps =
// [workspaceId]) so the IntersectionObserver effect below doesn't
// re-arm on every messages update.
const loadOlder = useCallback(async () => {
// inflightRef is the load-bearing guard — synchronous, set BEFORE
// any await, so two IO callbacks dispatched in the same microtask
// can't both pass. The state checks are defensive secondary
// gates for the slow-scroll case.
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef.current;
if (!container) return;
inflightRef.current = true;
// Capture the user's distance-from-bottom BEFORE we prepend so the
// useLayoutEffect can restore it after the new DOM lands. The
// expectFirstIdNotEqual tag is what the layout effect checks
// against `messages[0].id` to disambiguate prepend (id changed) vs
// append (id unchanged → live message landed mid-fetch). Without
// it, an agent push during loadOlder runs the "restore" against a
// stale anchor — user gets yanked + the append's bottom-pin is
// swallowed.
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
// Workspace switched (or another loadOlder bumped the token)
// mid-fetch — drop these results, they belong to a stale tab.
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
},
onActivityLog: (entry) => {
if (!sending) return;
setActivityLog((prev) => appendActivityLine(prev, entry));
},
onSendComplete: () => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
// Nothing came back — clear the anchor so the next paint doesn't
// try to "restore" against a no-op prepend.
scrollAnchorRef.current = null;
}
},
onSendError: (err) => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
setError(err);
}
},
});
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
//
// Dependencies:
// - loadOlder — stable per workspaceId (refs decouple it from
// message updates), so this dep is here for the
// workspace-switch case only
// - hasMore — re-run when older history runs out so we
// disconnect cleanly
// - hasMessages — load-bearing: the sentinel JSX is gated on
// `messages.length > 0`, so topRef.current is null
// on the empty-messages render. We re-arm exactly
// once when messages first land. NOT depending on
// `messages.length` (or `messages`) directly so
// each subsequent message append doesn't tear down
// + re-arm the observer.
const hasMessages = messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
if (!top || !container) return;
if (!hasMore) return; // stop observing when no older history exists
const ac = new AbortController();
const io = new IntersectionObserver(
(entries) => {
if (ac.signal.aborted) return;
if (entries[0]?.isIntersecting) loadOlder();
},
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
);
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
}, [loadOlder, hasMore, hasMessages]);
// Agent reachability
useEffect(() => {
const reachable = data.status === "online" || data.status === "degraded";
setAgentReachable(reachable);
if (reachable) {
setError(null);
clearSendError();
} else {
setError(`Agent is ${data.status}`);
}
}, [data.status, clearSendError]);
setError(reachable ? null : `Agent is ${data.status}`);
}, [data.status]);
useEffect(() => {
currentTaskRef.current = data.currentTask;
}, [data.currentTask]);
// Scroll behavior across messages updates:
// - Prepend (loadOlder landed) → restore the user's saved
@@ -180,24 +518,71 @@ function MyChatPanel({ workspaceId, data }: Props) {
// paint — otherwise the user sees the page jump for one frame.
useLayoutEffect(() => {
const container = containerRef.current;
const anchor = history.scrollAnchorRef.current;
const anchor = scrollAnchorRef.current;
// Only honor the anchor when this messages-update is the prepend
// we expected. messages[0].id is the test:
// - prepend → messages[0] is one of the older rows → id !== expectFirstIdNotEqual
// - append → messages[0] unchanged → id === expectFirstIdNotEqual → fall through
// Without this check, an agent push that lands mid-loadOlder would
// run the restore against the append's update, yank the user's
// scroll, AND swallow the append's bottom-pin.
if (
anchor &&
container &&
history.messages.length > 0 &&
history.messages[0].id !== anchor.expectFirstIdNotEqual
messages.length > 0 &&
messages[0].id !== anchor.expectFirstIdNotEqual
) {
container.scrollTop = container.scrollHeight - anchor.savedDistanceFromBottom;
history.scrollAnchorRef.current = null;
scrollAnchorRef.current = null;
return;
}
if (!hasInitialScrollRef.current && history.messages.length > 0) {
// Instant on first arrival of messages — smooth-scroll on a long
// conversation gets interrupted by concurrent renders and leaves
// the user stuck in the middle. After the first jump, subsequent
// appends animate as before.
if (!hasInitialScrollRef.current && messages.length > 0) {
hasInitialScrollRef.current = true;
bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
return;
}
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [history.messages, history.scrollAnchorRef]);
}, [messages]);
// Consume agent push messages (send_message_to_user) from global store.
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
// than the /a2a HTTP response — when that happens, the push is the
// authoritative "reply arrived" signal for the UI, so clear `sending`
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
// whichever path clears first wins.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
// Dedupe in case the agent proactively pushed the same text the
// HTTP /a2a response already delivered (observed with the Hermes
// runtime, which emits both a reply body and a send_message_to_user
// push for the same content). Attachments ride along with the
// message so files returned by the A2A_RESPONSE WS path render
// their download chips.
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content, m.attachments)));
}
if (sendingFromAPIRef.current && msgs.length > 0) {
// Reply arrived via WS push (e.g. claude-code SDK). Release all
// three guards together — without sendInFlightRef the next
// sendMessage() silently no-ops at the synchronous re-entry
// check.
releaseSendGuards();
}
}, [pendingAgentMsgs, workspaceId]);
// Resolve workspace ID → name for activity display
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
// Elapsed timer while sending
useEffect(() => {
@@ -224,43 +609,211 @@ function MyChatPanel({ workspaceId, data }: Props) {
setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]);
}, [sending, data.runtime]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
const hasMessages = history.messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
if (!top || !container) return;
if (!history.hasMore) return;
const ac = new AbortController();
const io = new IntersectionObserver(
(entries) => {
if (ac.signal.aborted) return;
if (entries[0]?.isIntersecting) history.loadOlder();
},
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
);
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
}, [history.loadOlder, history.hasMore, hasMessages]);
// Subscribe to global WS via the singleton ReconnectingSocket (no
// per-component WebSocket — the previous pattern dropped events
// silently on any reconnect because each panel's raw socket had no
// onclose handler).
useSocketEvent((msg) => {
if (!sending) return;
try {
if (msg.event === "ACTIVITY_LOGGED") {
// Filter to events for THIS workspace. The platform's
// BroadcastOnly fires to every connected client, and
// without this guard a sibling workspace's a2a_send would
// surface as "→ Delegating to X..." inside the wrong
// chat panel. (workspace_id on the WS envelope is the
// workspace whose activity_log row we just wrote.)
if (msg.workspace_id !== workspaceId) return;
const handleSend = async () => {
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
// The platform logs a successful a2a_receive once the workspace
// has fully produced its reply. That's the authoritative "done"
// signal for the spinner — clear it even if the reply hasn't
// surfaced through the store yet (it may be delivered shortly
// via pendingAgentMsgs or the HTTP .then()).
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
releaseSendGuards();
}
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
releaseSendGuards();
setError("Agent error (Exception) — see workspace logs for details.");
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
// Per-tool-use telemetry from claude_sdk_executor's
// _report_tool_use. The summary already carries an icon
// + human-readable args (📄 Read /path, ⚡ Bash: …)
// so we render it verbatim. No icon prefix here — the
// emoji at the start of summary is the visual marker.
if (summary) line = summary;
}
if (line) {
setActivityLog((prev) => appendActivityLine(prev, line));
}
} else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
setActivityLog((prev) => appendActivityLine(prev, `${task}`));
}
}
// A2A_RESPONSE is already consumed by the store and its text is
// appended to messages via the pendingAgentMsgs effect above; we
// don't need to duplicate it here.
} catch { /* ignore */ }
});
const sendMessage = async () => {
const text = input.trim();
const files = pendingFiles;
if ((!text && files.length === 0) || !agentReachable || sending || uploading) return;
const filesToSend = pendingFiles;
// Allow sending if EITHER text OR attachments are present — a user
// can drop a file with no text and the agent still receives it.
if ((!text && filesToSend.length === 0) || !agentReachable || sending || uploading) return;
// Synchronous re-entry guard — see sendInFlightRef comment.
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
// Upload attachments first so we can include URIs in the A2A
// message parts. Sequential-before-send: a message with references
// to files not yet staged would fail agent-side; staging happens
// synchronously via /chat/uploads before message/send dispatch.
let uploaded: ChatAttachment[] = [];
if (filesToSend.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, filesToSend);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed");
return;
}
setUploading(false);
}
setInput("");
setPendingFiles([]);
clearSendError();
setMessages((prev) => [...prev, createMessage("user", text, uploaded)]);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
await sendMessage(text, files);
// Capture this send's token so the .then()/.catch() callbacks can
// detect a newer send that may have superseded them. See the
// sendTokenRef declaration for the race scenario this closes.
const myToken = ++sendTokenRef.current;
// Build conversation history from prior messages (last 20)
const history = messages
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
// A2A parts: text part (if any) + file parts (per attachment). The
// agent sees both in a single turn, matching the A2A spec shape.
// Wire shape is v0 — see A2APart definition above.
const parts: A2APart[] = [];
if (text) parts.push({ kind: "text", text });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
// A2A calls can legitimately take minutes — LLM latency +
// multi-turn tool use is common on slower providers (Hermes+minimax,
// Claude Code invoking bash/file tools, etc.). The 15s default
// would silently abort the fetch here, leaving the server to
// complete the reply and the user staring at
// "agent may be unreachable". Match the upload timeout (60s × 2)
// for the happy-path ceiling; anything longer is genuinely stuck.
api.post<A2AResponse>(`/workspaces/${workspaceId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
}, { timeoutMs: 120_000 })
.then((resp) => {
// Bail without touching any flags if a newer sendMessage has
// already run — its myToken bumped sendTokenRef, so this is
// a stale callback for an earlier message. The newer send
// owns the in-flight guards now.
if (sendTokenRef.current !== myToken) return;
// Skip if the WS A2A_RESPONSE event already handled this response.
// Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears
// it first wins, the other becomes a no-op (no duplicate messages).
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask((resp?.result ?? {}) as Record<string, unknown>);
if (replyText || replyFiles.length > 0) {
setMessages((prev) =>
appendMessageDeduped(prev, createMessage("agent", replyText, replyFiles)),
);
}
releaseSendGuards();
})
.catch(() => {
// Stale-callback guard — same rationale as .then().
if (sendTokenRef.current !== myToken) return;
// Same dedup guard as .then(): if a WS path (pendingAgentMsgs
// or ACTIVITY_LOGGED a2a_receive ok) already delivered the
// reply, sendingFromAPIRef is already false and there's
// nothing to roll back. Surfacing "Failed to send" here would
// contradict the agent reply the user is currently reading —
// exactly the false-positive observed when the HTTP request
// hung up (proxy idle / 502) after WS already won.
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
};
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
// Deduplicate against current pending set by name+size — user
// picking the same file twice shouldn't append it.
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
@@ -271,7 +824,35 @@ function MyChatPanel({ workspaceId, data }: Props) {
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
// Monotonic counter so two paste events within the same wall-clock
// second still produce distinct filenames. Without this, on
// Firefox (where pasted images have an empty `file.name`), two
// pastes ~100ms apart could yield identical synthetic names AND
// identical sizes, collapsing into one attachment via the
// `name:size` dedup in onFilesPicked.
const pasteCounterRef = useRef(0);
/** Paste-from-clipboard image attachment.
*
* Browser clipboard image items arrive as `File`s whose `name` is
* often a generic "image.png" (Chrome) or empty (Firefox/Safari),
* so two consecutive screenshot pastes collide on the name+size
* dedup the file-picker uses. Re-tag each pasted image with a
* per-paste unique name so dedup keeps them apart and the upload
* pipeline (which expects a non-empty filename) is happy.
*
* Falls through to onFilesPicked via direct File[] (NOT through
* the DataTransfer constructor — that throws on Safari < 14.1
* and old Edge, silently aborting the paste).
*
* Only intercepts the paste when the clipboard has at least one
* image; text-only pastes fall through to the textarea's default
* behaviour. */
const mimeToExt = (mime: string): string => {
// Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`,
// `"jpeg"`, `"webp"` etc. which produce ugly filenames and may
// trip server-side extension allowlists. Map known types
// explicitly; unknown falls back to a safe default.
if (mime === "image/svg+xml") return "svg";
if (mime === "image/jpeg") return "jpg";
if (mime === "image/png") return "png";
@@ -292,16 +873,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
const file = item.getAsFile();
if (!file) continue;
const ext = mimeToExt(file.type);
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const stamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const seq = pasteCounterRef.current++;
const fname = `pasted-${stamp}-${seq}-${i}.${ext}`;
imageFiles.push(new File([file], fname, { type: file.type }));
}
if (imageFiles.length === 0) return;
e.preventDefault();
// Reuse the picker path so file-size guards, dedup, and pending-
// list state all run through the same code. Build a synthetic
// FileList-like object to avoid the DataTransfer constructor —
// that's missing on Safari < 14.1 / old Edge and would silently
// throw, leaving the paste a no-op.
addPastedFiles(imageFiles);
};
// Variant of onFilesPicked that accepts a File[] directly, sidestepping
// the DataTransfer-FileList round-trip. Same dedup + state shape.
const addPastedFiles = (files: File[]) => {
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
@@ -309,6 +900,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
});
};
// Drag-and-drop staging. dragDepthRef counts enter vs leave events so
// the overlay doesn't flicker when the cursor crosses nested children
// (textarea, buttons) — dragenter/dragleave fire for every boundary.
const [dragOver, setDragOver] = useState(false);
const dragDepthRef = useRef(0);
const dropEnabled = agentReachable && !sending && !uploading;
const isFileDrag = (e: React.DragEvent) =>
Array.from(e.dataTransfer.types || []).includes("Files");
@@ -338,6 +934,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
};
const downloadAttachment = (att: ChatAttachment) => {
// Errors here are rare but user-visible (401 on a revoked token,
// 404 if the agent deleted the file). Surface via the inline
// error banner — the message list itself stays untouched.
downloadChatFile(workspaceId, att).catch((e) => {
setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed");
});
@@ -391,26 +990,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
)}
{/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{history.loading && (
{loading && (
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
)}
{!history.loading && history.loadError !== null && history.messages.length === 0 && (
{!loading && loadError !== null && messages.length === 0 && (
<div
role="alert"
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
>
<p className="text-[11px] text-bad mb-1.5">
Failed to load chat history: {history.loadError}
Failed to load chat history: {loadError}
</p>
<button
onClick={history.loadInitial}
onClick={loadInitial}
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
{!history.loading && history.loadError === null && history.messages.length === 0 && (
{!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">
No messages yet. Send a message to start chatting with this agent.
</div>
@@ -428,12 +1027,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
instead of showing a "no more messages" footer — the user's
scroll resting against the top of the conversation IS the
signal. */}
{history.hasMore && history.messages.length > 0 && (
{hasMore && messages.length > 0 && (
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
{history.loadingOlder ? "Loading older messages…" : " "}
{loadingOlder ? "Loading older messages…" : " "}
</div>
)}
{history.messages.map((msg) => (
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
@@ -593,10 +1192,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
{/* Error banner */}
{displayError && (
{error && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between">
<span className="text-[10px] text-red-300">{displayError}</span>
<span className="text-[10px] text-red-300">{error}</span>
{!isOnline && (
<button
onClick={() => setConfirmRestart(true)}
@@ -664,7 +1263,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
e.keyCode !== 229
) {
e.preventDefault();
handleSend();
sendMessage();
}
}}
onPaste={onPasteIntoComposer}
@@ -674,7 +1273,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
/>
<button
onClick={handleSend}
onClick={sendMessage}
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
>
+1 -1
View File
@@ -176,7 +176,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
// exactly the point of the platform adaptor. The deep `~/.hermes/
// config.yaml` on the container is a separate runtime-internal file,
// not this one.
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
+3 -46
View File
@@ -45,54 +45,11 @@ export function FilesTab({ workspaceId, data }: Props) {
if (data && isExternalLikeRuntime(data.runtime)) {
return <NotAvailablePanel runtime={data.runtime} />;
}
return <PlatformOwnedFilesTab workspaceId={workspaceId} runtime={data?.runtime} />;
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
}
/** Picks the initial root for the FilesTab dropdown based on the
* workspace's runtime. Decision: per-runtime default (Hongming
* 2026-05-15, internal#425 Decisions §2).
*
* - openclaw → `/agent-home` (the agent's identity/state — the
* user-facing interesting files for that runtime live in
* `~/.openclaw/` inside the container, which `/agent-home` maps to
* via the Phase 2b docker-exec backend).
* - everything else (claude-code, hermes, external-like, undefined)
* → `/configs` (the legacy default — managed config that flows
* through the per-runtime indirection in
* workspace-server/internal/handlers/template_files_eic.go).
*
* When the runtime is undefined (legacy callers that don't thread
* `data` through, or a workspace whose runtime field hasn't loaded
* yet) the default is `/configs` — matches today's behaviour, no
* surprise.
*
* Note on `/agent-home` pre-Phase-2b: the backend short-circuits
* with HTTP 501 and the canonical "implementation pending" body.
* The tab renders empty + the error banner explains. This is by
* design — lets us land the canvas UX before the backend ships,
* per the RFC's phased rollout. The 501 is graceful: it doesn't
* poison error toasts or generate "workspace not found" noise.
*
* Adding a new runtime that should default to `/agent-home`: add it
* to the agentHomeDefaultRuntimes set below. Adding a runtime that
* should default to a different root: extend this function. */
const agentHomeDefaultRuntimes = new Set(["openclaw"]);
function defaultRootForRuntime(runtime: string | undefined): string {
if (runtime && agentHomeDefaultRuntimes.has(runtime)) {
return "/agent-home";
}
return "/configs";
}
function PlatformOwnedFilesTab({
workspaceId,
runtime,
}: {
workspaceId: string;
runtime?: string;
}) {
const [root, setRoot] = useState(() => defaultRootForRuntime(runtime));
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
const [root, setRoot] = useState("/configs");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState("");
const [editContent, setEditContent] = useState("");
@@ -3,22 +3,6 @@
import { useRef } from "react";
import { getIcon } from "./tree";
// secretShapeMarker is the canonical body the workspace-server Files
// API returns when a file's path OR content matched a credential
// regex (internal#425 RFC, Phase 2b — backed by
// workspace-server/internal/secrets.ScanBytes). The marker is a
// fixed prefix so the canvas can detect it without parsing JSON and
// without round-tripping the matched bytes through the editor (which
// would defeat the purpose — clipboard, browser history, log
// surfaces would all see them).
//
// Today (Phase 1 / before 2b ships) the backend returns 501 for the
// only root that uses this path, so the marker is dead code until
// 2b lands. Wiring it in now keeps the canvas + backend contracts
// aligned in one PR rather than a follow-up. The constant is
// importable so a future test can pin the exact string.
export const SECRET_SHAPE_DENIED_MARKER = "<denied: secret-shape>";
interface Props {
selectedFile: string | null;
fileContent: string;
@@ -47,22 +31,6 @@ export function FileEditor({
const editorRef = useRef<HTMLTextAreaElement>(null);
const isDirty = editContent !== fileContent;
// internal#425 Phase 3: detect the secret-shape denial marker and
// render a placeholder instead of the editor. The marker comes
// from workspace-server Phase 2b (secrets.ScanBytes) which refuses
// to surface the file's bytes. We deliberately don't expose
// the matched pattern's Name here — the canvas just shows the
// generic denial. The Files API log surface has the Pattern.Name
// for operators who need to debug a false positive.
const isSecretShapeDenied = fileContent === SECRET_SHAPE_DENIED_MARKER;
// /agent-home is read-only from the canvas (Phase 2b ships read +
// delete; Phase-2b-followup may add write). Edits to /configs are
// unchanged. Until 2b ships, /agent-home returns 501 so this
// read-only gate is also dead code, but wiring it in now keeps
// the UI honest the moment 2b lands without a follow-up canvas PR.
const isReadOnlyRoot = root !== "/configs";
if (!selectedFile) {
return (
<div className="flex-1 flex items-center justify-center">
@@ -107,42 +75,11 @@ export function FileEditor({
{/* Editor area */}
{loadingFile ? (
<div className="p-4 text-xs text-ink-mid">Loading...</div>
) : isSecretShapeDenied ? (
// Files API refused to surface this file's bytes because its
// path or content matched a credential regex
// (workspace-server/internal/secrets, internal#425 Phase 2b).
// We render a placeholder INSTEAD OF the textarea so the
// matched bytes never enter the DOM. Clipboard / view-source
// / element-inspector all see the placeholder, not the
// credential.
<div
role="region"
aria-label="File content denied"
className="flex-1 flex items-center justify-center p-6 bg-surface"
>
<div className="max-w-md text-center space-y-2">
<div className="text-2xl opacity-40">🛡</div>
<p className="text-[11px] font-mono text-warm">
{SECRET_SHAPE_DENIED_MARKER}
</p>
<p className="text-[10px] text-ink-mid leading-relaxed">
The platform refused to surface this file because its
path or content matched a credential-shape pattern.
The bytes never left the workspace container.
</p>
<p className="text-[10px] text-ink-mid leading-relaxed">
If this is a false positive (test fixture, docs example,
or content that happens to share a credential's shape),
rename the file or adjust the content via the workspace
terminal so the regex no longer matches, then refresh.
</p>
</div>
</div>
) : (
<textarea
ref={editorRef}
value={editContent}
readOnly={isReadOnlyRoot}
readOnly={root !== "/configs"}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
@@ -38,15 +38,6 @@ export function FilesToolbar({
<option value="/home">/home</option>
<option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option>
{/* internal#425 Phase 1+3: container-internal $HOME root.
Backend lands the docker-exec dispatch in Phase 2b. Until
then the stub returns 501 with a canonical
"implementation pending" message — the dropdown renders
the option so the canvas affordance is design-frozen
even before the backend ships.
Runtime-default selection logic in FilesTab.tsx picks
this as the initial value for openclaw workspaces. */}
<option value="/agent-home">/agent-home</option>
</select>
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
</div>
@@ -1,181 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for the /agent-home root selector + per-runtime default-root
* + secret-shape denial placeholder (internal#425 Phase 3).
*
* Separate file so the diff is reviewable as a unit and the existing
* FilesToolbar / FileEditor / FilesTab tests don't have to grow
* agent-home-specific cases. Once Phase 2b lands, the read-only +
* 501-stub assertions here can be tightened (or moved into the main
* test file as the agent-home root becomes a first-class affordance).
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FilesToolbar } from "../FilesToolbar";
import {
FileEditor,
SECRET_SHAPE_DENIED_MARKER,
} from "../FileEditor";
afterEach(cleanup);
describe("internal#425 Phase 3 — /agent-home root selector", () => {
it("dropdown includes /agent-home as an option", () => {
// Pins the affordance is in the DOM even pre-Phase-2b — the
// canvas design freezes today, the backend lands the dispatch
// later. Without this, a future refactor that drops the option
// would silently regress the RFC's Phase 1 contract (canvas
// visibility) without breaking any other test.
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>,
);
const select = screen.getByRole("combobox", {
name: /file root directory/i,
}) as HTMLSelectElement;
const values = Array.from(select.options).map((o) => o.value);
expect(values).toContain("/agent-home");
});
it("dropdown shows /agent-home as the SELECTED root when prop is /agent-home", () => {
render(
<FilesToolbar
root="/agent-home"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>,
);
const select = screen.getByRole("combobox", {
name: /file root directory/i,
}) as HTMLSelectElement;
expect(select.value).toBe("/agent-home");
});
});
describe("internal#425 Phase 3 — secret-shape denial placeholder", () => {
// Files API Phase 2b returns SECRET_SHAPE_DENIED_MARKER as the file
// body when the file's path or content matched a credential regex.
// The editor MUST render the marker as a placeholder, not pump it
// through the textarea — that would put the marker (and any future
// matched bytes if the backend contract changes) into the DOM
// value, clipboard, and inspector.
it("renders the denial placeholder INSTEAD of the textarea when fileContent is the marker", () => {
render(
<FileEditor
selectedFile="agent/.openclaw/secrets.env"
fileContent={SECRET_SHAPE_DENIED_MARKER}
editContent={SECRET_SHAPE_DENIED_MARKER}
setEditContent={vi.fn()}
loadingFile={false}
saving={false}
success={null}
root="/agent-home"
onSave={vi.fn()}
onDownload={vi.fn()}
/>,
);
// Placeholder region present
expect(
screen.getByRole("region", { name: /file content denied/i }),
).toBeTruthy();
// Marker text visible (so a debugging operator sees the canonical
// contract string without having to dig into the source).
expect(screen.getByText(SECRET_SHAPE_DENIED_MARKER)).toBeTruthy();
// Critically: NO textarea — the bytes never reach a controlled
// input. A regression that re-introduces the textarea path would
// make the matched marker (and any future content) selectable +
// copyable.
expect(screen.queryByRole("textbox")).toBeNull();
});
it("renders the textarea normally when fileContent is regular content", () => {
render(
<FileEditor
selectedFile="config.yaml"
fileContent="name: openclaw\n"
editContent="name: openclaw\n"
setEditContent={vi.fn()}
loadingFile={false}
saving={false}
success={null}
root="/configs"
onSave={vi.fn()}
onDownload={vi.fn()}
/>,
);
expect(screen.getByRole("textbox")).toBeTruthy();
expect(screen.queryByRole("region", { name: /file content denied/i }))
.toBeNull();
});
it("/agent-home renders textarea READ-ONLY for non-denied content", () => {
// Phase 2b ships read + delete on /agent-home; write semantics
// are decided later. Until then, the canvas presents the editor
// as read-only so a user can't type into a buffer that the
// backend will refuse to PUT. Without this gate, the user would
// edit, hit Save, get a 501, and lose their context for why.
render(
<FileEditor
selectedFile=".openclaw/agent-card.json"
fileContent='{"name":"openclaw"}'
editContent='{"name":"openclaw"}'
setEditContent={vi.fn()}
loadingFile={false}
saving={false}
success={null}
root="/agent-home"
onSave={vi.fn()}
onDownload={vi.fn()}
/>,
);
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(textarea.readOnly).toBe(true);
});
it("/configs renders textarea WRITABLE (regression guard for the read-only gate)", () => {
render(
<FileEditor
selectedFile="config.yaml"
fileContent="name: x\n"
editContent="name: x\n"
setEditContent={vi.fn()}
loadingFile={false}
saving={false}
success={null}
root="/configs"
onSave={vi.fn()}
onDownload={vi.fn()}
/>,
);
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(textarea.readOnly).toBe(false);
});
});
describe("internal#425 Phase 3 — marker constant is the canonical string", () => {
// The marker string is part of the canvas <-> workspace-server
// contract. The workspace-server emits this exact body; the canvas
// detects it by exact-equality. A typo on either side would
// silently break detection — the canvas would render the literal
// string in the textarea instead of the placeholder. Pin the
// contract value here.
it("matches the contract value '<denied: secret-shape>'", () => {
expect(SECRET_SHAPE_DENIED_MARKER).toBe("<denied: secret-shape>");
});
});
@@ -1,3 +0,0 @@
export { useChatHistory } from "./useChatHistory";
export { useChatSend } from "./useChatSend";
export { useChatSocket } from "./useChatSocket";
@@ -1,11 +0,0 @@
"use client";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
/** Resolve a workspace ID to its human-readable name.
* Falls back to the first 8 chars of the ID. */
export function resolveWorkspaceName(id: string): string {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}
@@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
const INITIAL_HISTORY_LIMIT = 10;
const OLDER_HISTORY_BATCH = 20;
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
export interface ScrollAnchor {
savedDistanceFromBottom: number;
expectFirstIdNotEqual: string | null;
}
export function useChatHistory(
workspaceId: string,
containerRef?: React.RefObject<HTMLDivElement | null>,
) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fetchTokenRef = useRef(0);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
const inflightRef = useRef(false);
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
return loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadOlder = useCallback(async () => {
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef?.current;
if (!container) return;
inflightRef.current = true;
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
scrollAnchorRef.current = null;
}
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId, containerRef]);
return {
messages,
loading,
loadError,
loadingOlder,
hasMore,
loadInitial,
loadOlder,
appendMessageDeduped: (msg: ChatMessage) =>
setMessages((prev) => appendMessageDedupedFn(prev, msg)),
setMessages,
scrollAnchorRef,
};
}
@@ -1,182 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import { uploadChatFiles } from "../uploads";
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
import { extractFilesFromTask } from "../message-parser";
interface A2APart {
kind: string;
text?: string;
file?: {
name?: string;
mimeType?: string;
uri?: string;
size?: number;
};
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
export interface UseChatSendOptions {
getHistoryMessages: () => ChatMessage[];
onUserMessage?: (msg: ChatMessage) => void;
onAgentMessage?: (msg: ChatMessage) => void;
}
export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendInFlightRef = useRef(false);
const sendingFromAPIRef = useRef(false);
const sendTokenRef = useRef(0);
const optionsRef = useRef(options);
optionsRef.current = options;
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const clearError = useCallback(() => setError(null), []);
const sendMessage = useCallback(
async (text: string, files: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && files.length === 0) || sending || uploading) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
let uploaded: ChatAttachment[] = [];
if (files.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, files);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
);
return;
}
setUploading(false);
}
const userMsg = createMessage("user", trimmed, uploaded);
optionsRef.current.onUserMessage?.(userMsg);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
const myToken = ++sendTokenRef.current;
const history = optionsRef.current
.getHistoryMessages()
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
const parts: A2APart[] = [];
if (trimmed) parts.push({ kind: "text", text: trimmed });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
api
.post<A2AResponse>(
`/workspaces/${workspaceId}/a2a`,
{
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
},
{ timeoutMs: 120_000 },
)
.then((resp) => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask(
(resp?.result ?? {}) as Record<string, unknown>,
);
if (replyText || replyFiles.length > 0) {
optionsRef.current.onAgentMessage?.(
createMessage("agent", replyText, replyFiles),
);
}
releaseSendGuards();
})
.catch(() => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
},
[workspaceId, sending, uploading],
);
return {
sending,
uploading,
sendMessage,
error,
clearError,
releaseSendGuards,
sendingFromAPIRef,
};
}
@@ -1,100 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { createMessage, type ChatMessage } from "../types";
export interface UseChatSocketCallbacks {
onAgentMessage?: (msg: ChatMessage) => void;
onActivityLog?: (entry: string) => void;
onSendComplete?: () => void;
onSendError?: (error: string) => void;
}
export function useChatSocket(
workspaceId: string,
callbacks: UseChatSocketCallbacks,
): void {
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
// Agent push messages from global store
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
callbacksRef.current.onAgentMessage?.(
createMessage("agent", m.content, m.attachments),
);
}
if (msgs.length > 0) {
callbacksRef.current.onSendComplete?.();
}
}, [pendingAgentMsgs, workspaceId]);
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
useSocketEvent((msg) => {
try {
if (msg.event === "ACTIVITY_LOGGED") {
if (msg.workspace_id !== workspaceId) return;
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) callbacksRef.current.onSendComplete?.();
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) {
callbacksRef.current.onSendComplete?.();
callbacksRef.current.onSendError?.(
"Agent error (Exception) — see workspace logs for details.",
);
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
if (summary) line = summary;
}
if (line) {
callbacksRef.current.onActivityLog?.(line);
}
} else if (
msg.event === "TASK_UPDATED" &&
msg.workspace_id === workspaceId
) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
callbacksRef.current.onActivityLog?.(`${task}`);
}
}
} catch {
/* ignore */
}
});
}
-3
View File
@@ -1,5 +1,2 @@
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
export { useChatHistory } from "./hooks/useChatHistory";
export { useChatSend } from "./hooks/useChatSend";
export { useChatSocket } from "./hooks/useChatSocket";
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { TestConnectionState, SecretGroup } from '@/types/secrets';
import { validateSecret, ApiError } from '@/lib/api/secrets';
import { validateSecret } from '@/lib/api/secrets';
interface TestConnectionButtonProps {
provider: SecretGroup;
@@ -55,23 +55,9 @@ export function TestConnectionButton({
}
onResult?.(result.valid);
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS[nextState]!);
} catch (err) {
// Distinguish a real failure shape rather than always claiming a
// timeout. A reachable server that answered with an HTTP status
// (ApiError) did NOT time out — most commonly the validation route
// is not available (404/501), which must not masquerade as
// "service down". Only an actual thrown network/abort error is a
// connectivity failure.
} catch {
setState('failure');
if (err instanceof ApiError) {
setErrorDetail(
err.status === 404 || err.status === 501
? 'Key validation is not available for this service yet. The key was not tested.'
: `Could not verify key (server returned ${err.status}). Saving is unaffected.`,
);
} else {
setErrorDetail('Could not reach the validation service. Check your connection and try again.');
}
setErrorDetail('Connection timed out. Service may be down.');
onResult?.(false);
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS.failure);
}
@@ -28,20 +28,8 @@ const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: (...args: unknown[]) => mockValidateSecret(...args),
ApiError: class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
},
}));
// Re-import the mocked ApiError so test cases construct the same class the
// component's `instanceof` check sees.
import { ApiError } from "@/lib/api/secrets";
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
@@ -213,27 +201,8 @@ describe("TestConnectionButton — failure path", () => {
});
describe("TestConnectionButton — catch path", () => {
it("does NOT claim a timeout when the validate endpoint 404s (regression: internal#492)", async () => {
// The validate route is unimplemented on the server and returns a fast
// 404. Before the fix this rendered the misleading hardcoded string
// "Connection timed out. Service may be down." It must instead state
// honestly that validation isn't available and the key was not tested.
mockValidateSecret.mockRejectedValue(new ApiError(404, "Not Found"));
render(
<TestConnectionButton provider="anthropic" secretValue="sk-ant-xxx" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).not.toContain("Connection timed out");
expect(document.body.textContent).not.toContain("Service may be down");
expect(document.body.textContent).toContain("not available");
expect(document.body.textContent).toContain("not tested");
});
it("reports a non-404 server error with its status, not a timeout", async () => {
mockValidateSecret.mockRejectedValue(new ApiError(500, "Internal Server Error"));
it("shows 'Connection timed out' on network error", async () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
@@ -241,20 +210,7 @@ describe("TestConnectionButton — catch path", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("500");
expect(document.body.textContent).not.toContain("Connection timed out");
});
it("shows a connectivity message on a genuine network error", async () => {
mockValidateSecret.mockRejectedValue(new Error("network down"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Could not reach the validation service");
expect(document.body.textContent).toContain("Connection timed out");
});
it("calls onResult(false) on network error", async () => {
+8 -12
View File
@@ -8,18 +8,14 @@ import { getTenantSlug } from "./tenant";
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
// 35s is long enough for the slowest server-side path (EIC SSH
// tunnel for tenant EC2 file operations, bounded server-side by
// `eicFileOpTimeout = 30 * time.Second` in
// workspace-server/internal/handlers/template_files_eic.go) so the
// canvas surfaces the server's real error instead of aborting first
// with a generic timeout. Shorter values caused "Save & Restart" to
// time out at the client before the backend returned its 5xx. The
// abort still propagates through AbortController so React components
// can render a retry affordance. Callers that know an endpoint is
// intentionally slow (org import walks a tree of workspaces with
// server-side pacing) can pass `timeoutMs` to override.
const DEFAULT_TIMEOUT_MS = 35_000;
// 15s is long enough for slow CP queries but short enough that a
// hung backend doesn't leave the UI spinning forever. The abort
// propagates through AbortController so React components can observe
// the error and render a retry affordance. Callers that know the
// endpoint is intentionally slow (org import walks a tree of
// workspaces with server-side pacing) can pass `timeoutMs` to
// override.
const DEFAULT_TIMEOUT_MS = 15_000;
export interface RequestOptions {
timeoutMs?: number;
+5 -5
View File
@@ -121,7 +121,7 @@ func main() {
case <-ctx.Done():
return
case <-ticker.C:
result, err := db.DB.ExecContext(ctx, `DELETE FROM activity_logs WHERE created_at < now() - ($1 || ' days')::interval`, retentionDays)
result, err := db.GetDB().ExecContext(ctx, `DELETE FROM activity_logs WHERE created_at < now() - ($1 || ' days')::interval`, retentionDays)
if err != nil {
log.Printf("Activity log cleanup error: %v", err)
} else if n, _ := result.RowsAffected(); n > 0 {
@@ -184,7 +184,7 @@ func main() {
// WorkspaceHandler) get the same plugin/resolver pair. memBundle
// is nil when MEMORY_PLUGIN_URL is unset — every consumer
// nil-checks before using.
memBundle := memwiring.Build(db.DB)
memBundle := memwiring.Build(db.GetDB())
if memBundle != nil {
wh.WithNamespaceCleanup(memBundle.NamespaceCleanupFn())
}
@@ -278,7 +278,7 @@ func main() {
// pending_uploads table grows unbounded; even with the 24h hard TTL,
// nothing actually deletes a row, just makes it un-fetchable.
go supervised.RunWithRecover(ctx, "pending-uploads-sweeper", func(c context.Context) {
pendinguploads.StartSweeper(c, pendinguploads.NewPostgres(db.DB), 0)
pendinguploads.StartSweeper(c, pendinguploads.NewPostgres(db.GetDB()), 0)
})
// Provision-timeout sweep — flips workspaces that have been stuck in
@@ -513,7 +513,7 @@ func fixAdminTokenPlaceholder() {
// Read the current stored value. We only upsert when the placeholder is
// present so we don't repeatedly write rows that are already correct.
var storedValue []byte
err := db.DB.QueryRow(`SELECT encrypted_value FROM global_secrets WHERE key = $1`, "ADMIN_TOKEN").Scan(&storedValue)
err := db.GetDB().QueryRow(`SELECT encrypted_value FROM global_secrets WHERE key = $1`, "ADMIN_TOKEN").Scan(&storedValue)
if err != nil {
// No row — nothing to fix. The control plane injects ADMIN_TOKEN via
// Secrets Manager bootstrap; the global_secrets path is a legacy seed.
@@ -545,7 +545,7 @@ func fixAdminTokenPlaceholder() {
return
}
_, err = db.DB.Exec(`
_, err = db.GetDB().Exec(`
INSERT INTO global_secrets (key, encrypted_value, encryption_version)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE
+2 -2
View File
@@ -28,7 +28,7 @@ func Export(ctx context.Context, workspaceID, configsDir string, dockerCli *clie
var agentCard []byte
var parentID *string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT name, COALESCE(role, ''), tier, status,
COALESCE(agent_card, 'null'::jsonb), parent_id
FROM workspaces WHERE id = $1
@@ -79,7 +79,7 @@ func Export(ctx context.Context, workspaceID, configsDir string, dockerCli *clie
}
// Recursively export sub-workspaces
rows, err := db.DB.QueryContext(ctx,
rows, err := db.GetDB().QueryContext(ctx,
`SELECT id FROM workspaces WHERE parent_id = $1 AND status != 'removed'`, workspaceID)
if err == nil {
defer func() { _ = rows.Close() }()
+4 -4
View File
@@ -41,7 +41,7 @@ func Import(
}
// Create workspace record
_, err := db.DB.ExecContext(ctx, `
_, err := db.GetDB().ExecContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, status, parent_id, source_bundle_id)
VALUES ($1, $2, $3, $4, 'provisioning', $5, $6)
`, wsID, b.Name, nilIfEmpty(b.Description), b.Tier, parentID, b.ID)
@@ -72,7 +72,7 @@ func Import(
}
}
// Store runtime in DB
_, _ = db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)
_, _ = db.GetDB().ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)
// Provision the container if provisioner is available
if prov != nil {
@@ -92,7 +92,7 @@ func Import(
if err != nil {
markFailed(provCtx, wsID, broadcaster, err)
} else if url != "" {
db.DB.ExecContext(provCtx, `UPDATE workspaces SET url = $1 WHERE id = $2`, url, wsID)
db.GetDB().ExecContext(provCtx, `UPDATE workspaces SET url = $1 WHERE id = $2`, url, wsID)
}
}()
}
@@ -139,7 +139,7 @@ func markFailed(ctx context.Context, wsID string, broadcaster *events.Broadcaste
// markProvisionFailed in workspace-server/internal/handlers/
// workspace_provision_shared.go.
msg := err.Error()
db.DB.ExecContext(ctx,
db.GetDB().ExecContext(ctx,
`UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3`,
models.StatusFailed, msg, wsID)
broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), wsID, map[string]interface{}{
@@ -600,7 +600,7 @@ func TestManager_SendOutbound_NoChatID(t *testing.T) {
// The callback is a package-level var set by NewManager; we verify both its
// default (safe no-op) and the wired-up path via a UPDATE assertion against
// a sqlmock-backed db.DB. Two tests guard the contract: the var is callable
// a sqlmock-backed db.GetDB(). Two tests guard the contract: the var is callable
// at zero-value, and a wired callback issues the right UPDATE.
func TestDisableChannelByChatID_DefaultIsNoOp(t *testing.T) {
+13 -13
View File
@@ -68,10 +68,10 @@ func NewManager(proxy A2AProxy, broadcaster Broadcaster) *Manager {
// row disabled and reload in-memory manager state. Without this, outbound
// messages keep trying the dead chat and log 403s forever.
disableChannelByChatID = func(ctx context.Context, chatID string) {
if db.DB == nil {
if db.GetDB() == nil {
return
}
res, err := db.DB.ExecContext(ctx, `
res, err := db.GetDB().ExecContext(ctx, `
UPDATE workspace_channels
SET enabled = false, updated_at = now()
WHERE channel_type = 'telegram'
@@ -122,7 +122,7 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
return func() {}
}
rows, err := db.DB.QueryContext(context.Background(), `
rows, err := db.GetDB().QueryContext(context.Background(), `
SELECT id, channel_config FROM workspace_channels
WHERE enabled = true AND workspace_id = $1
`, workspaceID)
@@ -185,7 +185,7 @@ func (m *Manager) Stop() {
// Reload re-reads enabled channels from DB and diffs against running pollers.
// New channels get started, removed/disabled channels get stopped.
func (m *Manager) Reload(ctx context.Context) {
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users
FROM workspace_channels
WHERE enabled = true
@@ -374,8 +374,8 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
m.appendHistory(ctx, historyKey, msg.Username, msg.Text, replyText)
// Update stats in DB
if db.DB != nil {
db.DB.ExecContext(ctx, `
if db.GetDB() != nil {
db.GetDB().ExecContext(ctx, `
UPDATE workspace_channels
SET last_message_at = now(), message_count = message_count + 1, updated_at = now()
WHERE id = $1
@@ -419,8 +419,8 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
}
}
if db.DB != nil {
db.DB.ExecContext(ctx, `
if db.GetDB() != nil {
db.GetDB().ExecContext(ctx, `
UPDATE workspace_channels
SET last_message_at = now(), message_count = message_count + 1, updated_at = now()
WHERE id = $1
@@ -447,7 +447,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
// completion posts to both #mol-engineering AND #mol-firehose if the
// workspace has both configured via chat_id comma-separation.
func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID, text string) {
if text == "" || db.DB == nil {
if text == "" || db.GetDB() == nil {
return
}
// Truncate to keep Slack messages digestible (rune-safe for CJK/emoji)
@@ -457,7 +457,7 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
}
// Only auto-post to Slack channels. Telegram is CEO-only — explicit
// escalations via the agent's outbound call, never auto-post from crons.
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id FROM workspace_channels
WHERE workspace_id = $1 AND enabled = true AND channel_type = 'slack'
`, workspaceID)
@@ -478,10 +478,10 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
// as ambient context for cron prompts (Level 3).
func (m *Manager) FetchWorkspaceChannelContext(ctx context.Context, workspaceID string) string {
if db.DB == nil {
if db.GetDB() == nil {
return ""
}
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT channel_config FROM workspace_channels
WHERE workspace_id = $1 AND channel_type = 'slack' AND enabled = true
LIMIT 1
@@ -548,7 +548,7 @@ func truncID(id string) string {
func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow, error) {
var ch ChannelRow
var configJSON, allowedJSON []byte
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users
FROM workspace_channels WHERE id = $1
`, channelID).Scan(&ch.ID, &ch.WorkspaceID, &ch.ChannelType, &configJSON, &ch.Enabled, &allowedJSON)
+42 -8
View File
@@ -8,24 +8,57 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
_ "github.com/lib/pq"
)
// mu guards DB against concurrent read/write. setupTestDB swaps the
// connection during test cleanup; concurrent goroutines from the test
// body may be reading DB at that moment.
var mu sync.RWMutex
// DB is the package-level postgres connection. In production it is set
// once by InitPostgres and never mutated. In tests, setupTestDB swaps it
// for a sqlmock. Access via GetDB() to avoid data races.
var DB *sql.DB
// GetDB returns the current *sql.DB, acquired under a read lock so that
// concurrent readers (async goroutines from test bodies) and writers
// (setupTestDB cleanup) do not race.
func GetDB() *sql.DB {
mu.RLock()
defer mu.RUnlock()
return DB
}
// Lock acquires an exclusive write lock on the DB. Used by test helpers
// (setupTestDB) to safely swap db.DB without racing against concurrent
// GetDB() readers.
func Lock() {
mu.Lock()
}
// Unlock releases the exclusive write lock acquired by Lock().
func Unlock() {
mu.Unlock()
}
func InitPostgres(databaseURL string) error {
var err error
DB, err = sql.Open("postgres", databaseURL)
conn, err := sql.Open("postgres", databaseURL)
if err != nil {
return fmt.Errorf("open postgres: %w", err)
}
DB.SetMaxOpenConns(25)
DB.SetMaxIdleConns(5)
conn.SetMaxOpenConns(25)
conn.SetMaxIdleConns(5)
if err := DB.Ping(); err != nil {
if err := conn.Ping(); err != nil {
return fmt.Errorf("ping postgres: %w", err)
}
mu.Lock()
DB = conn
mu.Unlock()
log.Println("Connected to Postgres")
return nil
}
@@ -51,8 +84,9 @@ func InitPostgres(databaseURL string) error {
// Migration authors must write idempotent SQL. A real schema_migrations
// tracking table would be better; tracked as follow-up.
func RunMigrations(migrationsDir string) error {
realDB := GetDB()
// Create tracking table if it doesn't exist.
if _, err := DB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
if _, err := realDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`); err != nil {
@@ -81,7 +115,7 @@ func RunMigrations(migrationsDir string) error {
// Check if already applied.
var exists bool
if err := DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)", base).Scan(&exists); err != nil {
if err := realDB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)", base).Scan(&exists); err != nil {
return fmt.Errorf("check migration %s: %w", base, err)
}
if exists {
@@ -94,12 +128,12 @@ func RunMigrations(migrationsDir string) error {
if err != nil {
return fmt.Errorf("read %s: %w", f, err)
}
if _, err := DB.Exec(string(content)); err != nil {
if _, err := realDB.Exec(string(content)); err != nil {
return fmt.Errorf("exec %s: %w", base, err)
}
// Record as applied.
if _, err := DB.Exec("INSERT INTO schema_migrations (filename) VALUES ($1)", base); err != nil {
if _, err := realDB.Exec("INSERT INTO schema_migrations (filename) VALUES ($1)", base); err != nil {
return fmt.Errorf("record migration %s: %w", base, err)
}
applied++
@@ -17,7 +17,9 @@ func TestRunMigrations_FirstBoot_AppliesAndRecords(t *testing.T) {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
mu.Lock()
DB = mockDB
mu.Unlock()
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_init.up.sql"), []byte("CREATE TABLE foo();"), 0o644)
@@ -55,7 +57,9 @@ func TestRunMigrations_SecondBoot_SkipsApplied(t *testing.T) {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
mu.Lock()
DB = mockDB
mu.Unlock()
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_init.up.sql"), []byte("CREATE TABLE foo();"), 0o644)
@@ -92,7 +96,9 @@ func TestRunMigrations_MixedState_AppliesOnlyNew(t *testing.T) {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
mu.Lock()
DB = mockDB
mu.Unlock()
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_old.up.sql"), []byte("SELECT 1;"), 0o644)
@@ -135,7 +141,9 @@ func TestRunMigrations_SkipsDownSqlFilesEvenInTracking(t *testing.T) {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
mu.Lock()
DB = mockDB
mu.Unlock()
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_init.up.sql"), []byte("CREATE TABLE foo();"), 0o644)
@@ -83,7 +83,7 @@ func TestWorkspaceStatusFailed_MustSetLastSampleError(t *testing.T) {
if !ok {
return true
}
// Match db.DB.ExecContext / db.DB.QueryContext / db.DB.QueryRowContext
// Match db.GetDB().ExecContext / db.GetDB().QueryContext / db.GetDB().QueryRowContext
// — the three SQL execution surfaces this codebase uses.
methodName := sel.Sel.Name
if methodName != "ExecContext" && methodName != "QueryContext" && methodName != "QueryRowContext" {
@@ -63,7 +63,7 @@ func (b *Broadcaster) RecordAndBroadcast(ctx context.Context, eventType string,
}
// Insert into structure_events — cast to jsonb explicitly
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO structure_events (event_type, workspace_id, payload)
VALUES ($1, $2, $3::jsonb)
`, eventType, workspaceID, string(payloadJSON))
@@ -276,7 +276,7 @@ func (h *WorkspaceHandler) ProxyA2A(c *gin.Context) {
if callerID == "" {
if _, isOrg := c.Get("org_token_id"); !isOrg {
if tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization")); tok != "" {
if wsID, err := wsauth.WorkspaceFromToken(ctx, db.DB, tok); err == nil {
if wsID, err := wsauth.WorkspaceFromToken(ctx, db.GetDB(), tok); err == nil {
callerID = wsID
}
}
@@ -332,7 +332,7 @@ func (h *WorkspaceHandler) ProxyA2A(c *gin.Context) {
func (h *WorkspaceHandler) checkWorkspaceBudget(ctx context.Context, workspaceID string) *proxyA2AError {
var budgetLimit sql.NullInt64
var monthlySpend int64
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&budgetLimit, &monthlySpend)
@@ -399,21 +399,7 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
// (no Do(), no maybeMarkContainerDead). The response is a synthetic
// {status:"queued"} envelope so the caller (canvas, another workspace)
// knows delivery is acknowledged but pending consumption.
deliveryMode, deliveryModeErr := lookupDeliveryMode(ctx, workspaceID)
if deliveryModeErr != nil {
// internal#497 fail-closed: a real DB/context error on the
// delivery-mode read MUST NOT silently fall through to the push
// dispatch path — that is exactly what silently misrouted every
// poll-mode peer for 5 days under the ce2db75f regression. Surface
// a structured error so the delegation is marked failed (loud +
// retryable) instead of dispatched to the wrong path.
log.Printf("ProxyA2A: delivery-mode lookup failed for %s: %v — failing closed", workspaceID, deliveryModeErr)
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{"error": "delivery-mode lookup failed; refusing to dispatch to avoid silent misrouting"},
}
}
if deliveryMode == models.DeliveryModePoll {
if lookupDeliveryMode(ctx, workspaceID) == models.DeliveryModePoll {
if logActivity {
h.logA2AReceiveQueued(ctx, workspaceID, callerID, body, a2aMethod)
}
@@ -637,7 +623,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
if err != nil {
var urlNullable sql.NullString
var status string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT url, status FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&urlNullable, &status)
if err == sql.ErrNoRows {
@@ -161,7 +161,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// canvas-chat-to-dead-workspace incident traces to exactly this gap.
func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspaceID string) bool {
var wsRuntime string
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
db.GetDB().QueryRowContext(ctx, `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
if isExternalLikeRuntime(wsRuntime) {
return false
}
@@ -189,17 +189,12 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
return false
}
log.Printf("ProxyA2A: container for %s is dead — marking offline and triggering restart", workspaceID)
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status NOT IN ('removed', 'provisioning')`, models.StatusOffline, workspaceID); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status NOT IN ('removed', 'provisioning')`, models.StatusOffline, workspaceID); err != nil {
log.Printf("ProxyA2A: failed to mark workspace %s offline: %v", workspaceID, err)
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
// Tracked via goAsync (not bare `go`) so the asyncWG can be drained
// before a test swaps the global db.DB. runRestartCycle reads db.DB
// before its provisioner gate, so an untracked detached goroutine
// races setupTestDB's t.Cleanup db.DB restore. Matches the already-
// correct site at a2a_proxy.go:648.
h.goAsync(func() { h.RestartByID(workspaceID) })
go h.RestartByID(workspaceID)
return true
}
@@ -239,17 +234,14 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
// (same effect as maybeMarkContainerDead's branch), and return the
// structured 503 immediately so the caller skips the forward.
log.Printf("ProxyA2A preflight: container for %s is not running — marking offline and triggering restart (#36)", workspaceID)
if _, dbErr := db.DB.ExecContext(ctx,
if _, dbErr := db.GetDB().ExecContext(ctx,
`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status NOT IN ('removed', 'provisioning')`,
models.StatusOffline, workspaceID); dbErr != nil {
log.Printf("ProxyA2A preflight: failed to mark workspace %s offline: %v", workspaceID, dbErr)
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
// Tracked via goAsync (see maybeMarkContainerDead): preflight's
// detached restart must be drainable so it doesn't race the global
// db.DB swap in test cleanup.
h.goAsync(func() { h.RestartByID(workspaceID) })
go h.RestartByID(workspaceID)
return &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{
@@ -265,13 +257,12 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int) {
errMsg := err.Error()
var errWsName string
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&errWsName)
db.GetDB().QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&errWsName)
if errWsName == "" {
errWsName = workspaceID
}
summary := "A2A request to " + errWsName + " failed: " + errMsg
parent := ctx
h.goAsync(func() {
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
@@ -286,7 +277,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
Status: "error",
ErrorDetail: &errMsg,
})
})
}(ctx)
}
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
@@ -298,7 +289,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
logStatus = "error"
}
var wsNameForLog string
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsNameForLog)
db.GetDB().QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsNameForLog)
if wsNameForLog == "" {
wsNameForLog = workspaceID
}
@@ -307,19 +298,18 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
// silent workspaces. Only update when callerID is a real workspace (not
// canvas, not a system caller) and the target returned 2xx/3xx.
if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
h.goAsync(func() {
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.DB.ExecContext(bgCtx,
if _, err := db.GetDB().ExecContext(bgCtx,
`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
}
})
}()
}
summary := a2aMethod + " → " + wsNameForLog
toolTrace := extractToolTrace(respBody)
parent := ctx
h.goAsync(func() {
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
@@ -335,7 +325,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
DurationMs: &durationMs,
Status: logStatus,
})
})
}(ctx)
if callerID == "" && statusCode < 400 {
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
@@ -364,7 +354,7 @@ func nilIfEmpty(s string) *string {
// On auth failure this writes the 401 via c and returns an error so the
// handler aborts without running the proxy.
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) error {
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.GetDB(), callerID)
if err != nil {
// Fail-open here matches the heartbeat path — A2A caller auth is
// defense-in-depth on top of access-control hierarchy, not the
@@ -381,7 +371,7 @@ func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) e
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing caller auth token"})
return errInvalidCallerToken
}
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
if err := wsauth.ValidateToken(ctx, db.GetDB(), callerID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid caller auth token"})
return err
}
@@ -468,64 +458,40 @@ func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
return 0, 0
}
// lookupDeliveryMode returns the workspace's delivery_mode.
//
// internal#497 / RFC#497 fail-closed (SURGICAL scope): the *specific*
// failure mode that hid the ce2db75f regression for 5 days is now
// propagated instead of silently swallowed — a CONTEXT error
// (context.Canceled / context.DeadlineExceeded). Under ce2db75f the
// detached delegation goroutine ran on a cancelled request context, every
// `SELECT delivery_mode` failed `context canceled`, this function returned
// push, the poll-mode short-circuit in proxyA2ARequest was skipped, and
// poll-mode peers (e.g. an operator laptop on molecule-mcp-claude-channel)
// silently never got their a2a_receive inbox row. A transient,
// systematic-once-triggered context cancellation became permanent
// invisible misrouting. Returning that error lets the caller fail loud
// (mark the delegation failed) instead of mis-dispatching.
//
// Scope is deliberately narrow: only ctx errors propagate. Other DB
// errors retain the long-standing documented "fall back to push (today's
// synchronous behavior)" contract — that path is loud + recoverable
// (502 / SSRF reject / restart), unlike the silent poll-mode drop, and
// the surrounding proxy (incl. the sibling checkWorkspaceBudget) is
// intentionally built around that fail-open-to-push behavior. Widening
// further is an RFC#497 follow-up, not part of this P0 fix.
//
// A genuinely *absent* configuration is NOT an error and still resolves to
// push (the safe synchronous default): sql.ErrNoRows, a NULL/empty column,
// or an unrecognised value all return (push, nil).
// lookupDeliveryMode returns the workspace's delivery_mode. On any DB
// error or missing row it returns DeliveryModePush — the fail-closed
// default. "Closed" here means "fall back to today's behavior (synchronous
// dispatch)" rather than "fall back to drop the request silently into
// activity_logs where the agent might never see it." A poll-mode workspace
// that briefly reads as push will get its A2A request dispatched to the
// stored URL (or a 502 if no URL); a push-mode workspace that briefly
// reads as poll would get its request silently queued with no dispatch.
// The first failure is loud + recoverable; the second is silent.
//
// The function is intentionally lookup-only — it never mutates the row.
// The register handler (registry.go) is the only writer for delivery_mode.
//
// See #2339 PR 1 for the column + register-flow side; this is the
// proxy-side read used for the short-circuit in proxyA2ARequest.
func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error) {
func lookupDeliveryMode(ctx context.Context, workspaceID string) string {
var mode sql.NullString
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&mode)
if err != nil {
// internal#497: a context cancellation/deadline MUST NOT be
// swallowed into a silent push default — that is the exact 5-day
// silent-misrouting vector. Propagate so the caller fails closed.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Printf("ProxyA2A: lookupDeliveryMode(%s) context error (%v) — failing closed (NOT defaulting to push)", workspaceID, err)
return "", err
}
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push (non-ctx DB error; legacy fail-open-to-push contract)", workspaceID, err)
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push", workspaceID, err)
}
return models.DeliveryModePush, nil
return models.DeliveryModePush
}
if !mode.Valid || mode.String == "" {
return models.DeliveryModePush, nil
return models.DeliveryModePush
}
if !models.IsValidDeliveryMode(mode.String) {
log.Printf("ProxyA2A: workspace %s has invalid delivery_mode=%q — defaulting to push", workspaceID, mode.String)
return models.DeliveryModePush, nil
return models.DeliveryModePush
}
return mode.String, nil
return mode.String
}
// logA2AReceiveQueued records a poll-mode "queued" A2A receive into
@@ -539,13 +505,12 @@ func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error)
// without a public URL.
func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string) {
var wsName string
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
db.GetDB().QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
if wsName == "" {
wsName = workspaceID
}
summary := a2aMethod + " → " + wsName + " (queued for poll)"
parent := ctx
h.goAsync(func() {
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
@@ -558,7 +523,7 @@ func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID,
RequestBody: json.RawMessage(body),
Status: "ok",
})
})
}(ctx)
}
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
@@ -2228,18 +2228,12 @@ func TestProxyA2A_PushMode_NoShortCircuit(t *testing.T) {
}
}
// TestProxyA2A_PollMode_FailsClosedToPush verifies the LEGACY safety
// contract is PRESERVED for non-context DB errors: a generic DB error
// reading delivery_mode still defaults to push (today's behavior), NOT
// poll. Failing to push means a poll-mode workspace briefly attempts a
// real dispatch — visible failure (502 / SSRF rejection / restart
// cascade), not a silent drop into activity_logs where the agent might
// never look. Loud > silent, recoverable > lost.
//
// internal#497 narrows the fail-closed change to *context* errors only
// (the actual ce2db75f regression vector); generic DB errors keep this
// long-standing fail-open-to-push contract. The ctx-error fail-closed is
// covered by TestLookupDeliveryMode_ContextCanceled_FailsClosed.
// TestProxyA2A_PollMode_FailsClosedToPush verifies the safety contract:
// a DB error reading delivery_mode must default to push (the existing
// behavior), NOT poll. Failing to push means a poll-mode workspace
// briefly attempts a real dispatch — visible failure (502 / SSRF
// rejection / restart cascade), not a silent drop into activity_logs
// where the agent might never look. Loud > silent, recoverable > lost.
func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t) // empty Redis — forces resolveAgentURL DB lookup
@@ -2250,8 +2244,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
expectBudgetCheck(mock, wsID)
// lookupDeliveryMode hits a generic (non-context) DB error → must
// still default push (legacy contract preserved by internal#497).
// lookupDeliveryMode hits a transient DB error → must default push.
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnError(sql.ErrConnDone)
@@ -2275,7 +2268,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] == "queued" {
t.Errorf("generic DB error on delivery_mode lookup silently queued the request — must fail-open-to-push, got body: %s", w.Body.String())
t.Errorf("DB error on delivery_mode lookup silently queued the request — must fail-closed-to-push, got body: %s", w.Body.String())
}
}
@@ -2284,37 +2277,6 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
}
}
// TestLookupDeliveryMode_ContextCanceled_FailsClosed is the internal#497
// regression test for the SECONDARY defect. It pins the exact invariant
// that hid the ce2db75f regression for 5 days: when the delivery_mode read
// fails because the context was cancelled (precisely what happened in the
// detached delegation goroutine running on a returned request context),
// lookupDeliveryMode MUST return an error and MUST NOT silently return
// "push". Returning push there is what skipped the poll-mode short-circuit
// and silently dropped 100% of poll-mode peer deliveries.
//
// A pre-cancelled context makes QueryRowContext fail with
// context.Canceled deterministically — no DB rows are mocked because the
// query never reaches a result.
func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
mock := setupTestDB(t)
// The query fails on the cancelled ctx before matching; provide a
// permissive expectation so sqlmock doesn't complain about the attempt.
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WillReturnError(context.Canceled)
ctx, cancel := context.WithCancel(context.Background())
cancel() // simulate the HTTP handler having returned (request ctx dead)
mode, err := lookupDeliveryMode(ctx, "ws-poll-peer")
if err == nil {
t.Fatalf("internal#497 regression: lookupDeliveryMode swallowed a context error and returned mode=%q with nil err — this is the exact 5-day silent-misrouting vector", mode)
}
if mode == models.DeliveryModePush {
t.Errorf("internal#497 regression: context error must NOT default to push (got mode=%q)", mode)
}
}
// ==================== a2aClient ResponseHeaderTimeout config ====================
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
+10 -10
View File
@@ -135,7 +135,7 @@ func EnqueueA2A(
// ON CONFLICT — only true CONSTRAINTs work for that). On conflict we
// then look up the existing row's id so the caller always receives a
// valid queue entry reference.
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
INSERT INTO a2a_queue (workspace_id, caller_id, priority, body, method, idempotency_key, expires_at)
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)
ON CONFLICT (workspace_id, idempotency_key)
@@ -146,7 +146,7 @@ func EnqueueA2A(
if errors.Is(err, sql.ErrNoRows) && idempotencyKey != "" {
// Conflict — look up the existing active row and use its id.
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
SELECT id FROM a2a_queue
WHERE workspace_id = $1 AND idempotency_key = $2
AND status IN ('queued','dispatched')
@@ -160,7 +160,7 @@ func EnqueueA2A(
}
// Return current queue depth for the caller's visibility.
_ = db.DB.QueryRowContext(ctx, `
_ = db.GetDB().QueryRowContext(ctx, `
SELECT COUNT(*) FROM a2a_queue
WHERE workspace_id = $1 AND status = 'queued'
`, workspaceID).Scan(&depth)
@@ -175,7 +175,7 @@ func EnqueueA2A(
//
// Returns (nil, nil) when the queue is empty — not an error.
func DequeueNext(ctx context.Context, workspaceID string) (*QueuedItem, error) {
tx, err := db.DB.BeginTx(ctx, nil)
tx, err := db.GetDB().BeginTx(ctx, nil)
if err != nil {
return nil, err
}
@@ -220,7 +220,7 @@ func DequeueNext(ctx context.Context, workspaceID string) (*QueuedItem, error) {
// MarkQueueItemCompleted flips the queue row to 'completed' on a successful
// drain dispatch.
func MarkQueueItemCompleted(ctx context.Context, id string) {
if _, err := db.DB.ExecContext(ctx,
if _, err := db.GetDB().ExecContext(ctx,
`UPDATE a2a_queue SET status = 'completed', completed_at = now() WHERE id = $1`, id,
); err != nil {
log.Printf("A2AQueue: failed to mark %s completed: %v", id, err)
@@ -233,7 +233,7 @@ func MarkQueueItemCompleted(ctx context.Context, id string) {
// forever.
func MarkQueueItemFailed(ctx context.Context, id, errMsg string) {
const maxAttempts = 5
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
UPDATE a2a_queue
SET status = CASE WHEN attempts >= $2 THEN 'failed' ELSE 'queued' END,
last_error = $3,
@@ -249,7 +249,7 @@ func MarkQueueItemFailed(ctx context.Context, id, errMsg string) {
// can see how many ahead of them.
func QueueDepth(ctx context.Context, workspaceID string) int {
var n int
_ = db.DB.QueryRowContext(ctx,
_ = db.GetDB().QueryRowContext(ctx,
`SELECT COUNT(*) FROM a2a_queue WHERE workspace_id = $1 AND status = 'queued'`,
workspaceID,
).Scan(&n)
@@ -266,7 +266,7 @@ func DropStaleQueueItems(ctx context.Context, workspaceID string, maxAgeMinutes
var rows int64
var err error
if workspaceID != "" {
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
WITH dropped AS (
UPDATE a2a_queue
SET status = 'dropped',
@@ -285,7 +285,7 @@ func DropStaleQueueItems(ctx context.Context, workspaceID string, maxAgeMinutes
SELECT count(*) FROM dropped
`, workspaceID, maxAgeMinutes).Scan(&rows)
} else {
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
WITH dropped AS (
UPDATE a2a_queue
SET status = 'dropped',
@@ -419,7 +419,7 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
"text": responseText,
"delegation_id": delegationID,
})
res, err := db.DB.ExecContext(ctx, `
res, err := db.GetDB().ExecContext(ctx, `
UPDATE activity_logs
SET status = 'completed',
summary = $1,
@@ -86,7 +86,7 @@ func QueueStatusByID(ctx context.Context, queueID string) (*QueueStatus, error)
// so a completed delegation surfaces its result inline — non-delegation
// queue rows simply won't have a matching activity_logs row and the field
// stays null.
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT
q.id,
q.workspace_id,
@@ -146,7 +146,7 @@ func QueueStatusByID(ctx context.Context, queueID string) (*QueueStatus, error)
// the auth check without first projecting the public response.
func queueRowAuthFields(ctx context.Context, queueID string) (callerID, workspaceID string, err error) {
var callerNS, workspaceNS sql.NullString
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = $1`,
queueID,
).Scan(&callerNS, &workspaceNS)
@@ -185,7 +185,7 @@ func (h *WorkspaceHandler) GetA2AQueueStatus(c *gin.Context) {
callerWorkspace := c.GetHeader("X-Workspace-ID")
if !isOrg && callerWorkspace == "" {
if tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization")); tok != "" {
if wsID, err := wsauth.WorkspaceFromToken(ctx, db.DB, tok); err == nil {
if wsID, err := wsauth.WorkspaceFromToken(ctx, db.GetDB(), tok); err == nil {
callerWorkspace = wsID
}
}
@@ -25,7 +25,7 @@ import (
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
// Uses the same global db.DB as setupTestDB so the handler can use it.
// Uses the same global db.GetDB() as setupTestDB so the handler can use it.
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
@@ -133,7 +133,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
var cursorTime time.Time
usingCursor := false
if sinceID != "" {
err := db.DB.QueryRowContext(c.Request.Context(),
err := db.GetDB().QueryRowContext(c.Request.Context(),
`SELECT created_at FROM activity_logs WHERE id = $1 AND workspace_id = $2`,
sinceID, workspaceID,
).Scan(&cursorTime)
@@ -222,7 +222,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
}
args = append(args, limit)
rows, err := db.DB.QueryContext(c.Request.Context(), query, args...)
rows, err := db.GetDB().QueryContext(c.Request.Context(), query, args...)
if err != nil {
log.Printf("Activity list error for %s: %v", workspaceID, err)
@@ -285,7 +285,7 @@ func (h *ActivityHandler) SessionSearch(c *gin.Context) {
sqlQuery, args := buildSessionSearchQuery(workspaceID, query, limit)
rows, err := db.DB.QueryContext(c.Request.Context(), sqlQuery, args...)
rows, err := db.GetDB().QueryContext(c.Request.Context(), sqlQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session search failed"})
return
@@ -476,7 +476,7 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
for _, a := range body.Attachments {
attachments = append(attachments, AgentMessageAttachment(a))
}
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
writer := NewAgentMessageWriter(db.GetDB(), h.broadcaster)
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
if errors.Is(err, ErrWorkspaceNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
@@ -587,7 +587,7 @@ func (h *ActivityHandler) Report(c *gin.Context) {
// most callers expect. For atomic-with-sibling-writes use LogActivityTx
// and propagate the error.
func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params ActivityParams) {
hook, err := logActivityExec(ctx, db.DB, broadcaster, params)
hook, err := logActivityExec(ctx, db.GetDB(), broadcaster, params)
if err != nil {
log.Printf("LogActivity insert error: %v", err)
return
@@ -615,7 +615,7 @@ func LogActivityTx(ctx context.Context, tx *sql.Tx, broadcaster events.EventEmit
// activityExecutor is the SQL surface LogActivity[Tx] needs. *sql.Tx
// and *sql.DB both satisfy it, so the same insert path serves the
// fire-and-forget caller (db.DB) and the Tx-aware caller (*sql.Tx).
// fire-and-forget caller (db.GetDB()) and the Tx-aware caller (*sql.Tx).
type activityExecutor interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
@@ -949,7 +949,7 @@ func TestLogActivityTx_DefersBroadcastUntilCommitHook(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
tx, err := db.DB.BeginTx(context.Background(), nil)
tx, err := db.GetDB().BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
@@ -993,7 +993,7 @@ func TestLogActivityTx_InsertError_NoHook_NoBroadcast(t *testing.T) {
WillReturnError(errors.New("constraint violation simulated"))
mock.ExpectRollback()
tx, err := db.DB.BeginTx(context.Background(), nil)
tx, err := db.GetDB().BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
@@ -52,7 +52,7 @@ type AdminDelegationsHandler struct {
func NewAdminDelegationsHandler(handle *sql.DB) *AdminDelegationsHandler {
if handle == nil {
handle = db.DB
handle = db.GetDB()
}
return &AdminDelegationsHandler{db: handle}
}
@@ -107,7 +107,7 @@ func (h *AdminMemoriesHandler) Export(c *gin.Context) {
return
}
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT am.id, am.content, am.scope, am.namespace, am.created_at,
w.name AS workspace_name
FROM agent_memories am
@@ -183,7 +183,7 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
for _, entry := range entries {
// 1. Resolve workspace by name
var workspaceID string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT id FROM workspaces WHERE name = $1 LIMIT 1`,
entry.WorkspaceName,
).Scan(&workspaceID)
@@ -205,7 +205,7 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
// secret (same placeholder output) are treated as duplicates.
var exists bool
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,
workspaceID, content, entry.Scope,
).Scan(&exists)
@@ -226,12 +226,12 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
}
if entry.CreatedAt != "" {
_, err = db.DB.ExecContext(ctx,
_, err = db.GetDB().ExecContext(ctx,
`INSERT INTO agent_memories (workspace_id, content, scope, namespace, created_at) VALUES ($1, $2, $3, $4, $5)`,
workspaceID, content, entry.Scope, namespace, entry.CreatedAt,
)
} else {
_, err = db.DB.ExecContext(ctx,
_, err = db.GetDB().ExecContext(ctx,
`INSERT INTO agent_memories (workspace_id, content, scope, namespace) VALUES ($1, $2, $3, $4)`,
workspaceID, content, entry.Scope, namespace,
)
@@ -277,7 +277,7 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
// N_workspaces resolver + N_workspaces plugin in the old code).
func (h *AdminMemoriesHandler) exportViaPlugin(c *gin.Context, ctx context.Context) {
// 1. One SQL pass: every workspace + its root id.
wsRows, err := loadWorkspacesWithRoots(ctx, db.DB)
wsRows, err := loadWorkspacesWithRoots(ctx, db.GetDB())
if err != nil {
log.Printf("admin/memories/export (cutover): workspaces query: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export query failed"})
@@ -445,7 +445,7 @@ func (h *AdminMemoriesHandler) importViaPlugin(c *gin.Context, ctx context.Conte
for _, entry := range entries {
var workspaceID string
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT id::text FROM workspaces WHERE name = $1 LIMIT 1`,
entry.WorkspaceName,
).Scan(&workspaceID); err != nil {
@@ -71,7 +71,7 @@ func (h *AdminPluginDriftHandler) Apply(c *gin.Context) {
TrackedRef string `json:"tracked_ref"`
Status string `json:"status"`
}
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT workspace_id, plugin_name, tracked_ref, status
FROM plugin_update_queue
WHERE id = $1
@@ -108,7 +108,7 @@ func (h *AdminPluginDriftHandler) Apply(c *gin.Context) {
// Step 2: read the workspace_plugins row to get source_raw.
var sourceRaw string
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
SELECT source_raw FROM workspace_plugins
WHERE workspace_id = $1 AND plugin_name = $2
`, entry.WorkspaceID, entry.PluginName).Scan(&sourceRaw)
@@ -177,7 +177,7 @@ func (h *AdminPluginDriftHandler) Apply(c *gin.Context) {
}
// Step 4: mark queue entry as applied.
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
UPDATE plugin_update_queue SET status = 'applied' WHERE id = $1
`, queueID); err != nil {
log.Printf("AdminPluginDrift: apply: failed to mark queue entry %s as applied: %v", queueID, err)
@@ -69,7 +69,7 @@ func (h *AdminSchedulesHealthHandler) Health(c *gin.Context) {
ctx := c.Request.Context()
now := time.Now()
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT
w.id AS workspace_id,
w.name AS workspace_name,
@@ -80,7 +80,7 @@ func (h *AdminTestTokenHandler) GetTestToken(c *gin.Context) {
// Confirm the workspace exists — a missing workspace also 404s so we
// can't be used to probe for arbitrary IDs.
var exists string
err := db.DB.QueryRowContext(c.Request.Context(),
err := db.GetDB().QueryRowContext(c.Request.Context(),
`SELECT id FROM workspaces WHERE id = $1`, workspaceID).Scan(&exists)
if err != nil {
if err == sql.ErrNoRows {
@@ -91,7 +91,7 @@ func (h *AdminTestTokenHandler) GetTestToken(c *gin.Context) {
return
}
token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID)
token, err := wsauth.IssueToken(c.Request.Context(), db.GetDB(), workspaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token issue failed"})
return
@@ -1,277 +1,225 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
wsauth "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
)
// Valid UUID used throughout.
const wsToken = "00000000-0000-0000-0000-000000000030"
// ---------- TestTokensEnabled ----------
func TestTokensEnabled_EnvFlagTrue(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
if !TestTokensEnabled() {
t.Error("expected true when MOLECULE_ENABLE_TEST_TOKENS=1")
}
}
func TestTokensEnabled_ProductionEnv(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "production")
if TestTokensEnabled() {
t.Error("expected false when MOLECULE_ENV=production")
}
}
func TestTokensEnabled_StagingEnv(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "staging")
if !TestTokensEnabled() {
t.Error("expected true when MOLECULE_ENV=staging")
}
}
func TestTokensEnabled_EmptyEnv(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "")
if !TestTokensEnabled() {
t.Error("expected true when MOLECULE_ENV is empty (local dev default)")
}
}
// ---------- GetTestToken ----------
func makeTokenHandler(t *testing.T) (*AdminTestTokenHandler, sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
return NewAdminTestTokenHandler(), mock, func() {
// Per agent-reviewer #7034: missing ExpectationsWereMet lets
// tests pass silently when the handler skips an expected
// SELECT/INSERT. Verify in cleanup so the failure is loud.
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
db.DB = prevDB
mockDB.Close()
}
}
func getTestToken(t *testing.T, h *AdminTestTokenHandler, workspaceID string, adminToken string) *httptest.ResponseRecorder {
t.Helper()
func newTestTokenRequest(workspaceID string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
req := httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
if adminToken != "" {
req.Header.Set("Authorization", "Bearer "+adminToken)
}
c.Request = req
h.GetTestToken(c)
return w
c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil)
return w, c
}
func TestGetTestToken_DisabledByDefault(t *testing.T) {
// Set MOLECULE_ENV=production to simulate a locked-down environment.
func TestAdminTestToken_HiddenInProduction(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "")
t.Setenv("MOLECULE_ENV", "production")
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 when disabled, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 404 in production, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_AdminTokenRequired_WrongToken(t *testing.T) {
// Set up: tokens enabled, ADMIN_TOKEN set, but request uses wrong token.
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("ADMIN_TOKEN", "correct-secret")
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "wrong-token")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_AdminTokenRequired_MissingBearer(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("ADMIN_TOKEN", "correct-secret")
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 when bearer missing, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_AdminTokenRequired_CorrectToken(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("ADMIN_TOKEN", "correct-secret")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
// IssueToken returns a token — we just need to verify the query ran.
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "correct-secret")
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_WorkspaceNotFound(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
// ADMIN_TOKEN not set — no auth header required.
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("missing").
WillReturnError(sqlErrNoRows())
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
w, c := newTestTokenRequest("missing")
h.GetTestToken(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_IssueTokenDBError(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
// IssueToken fails.
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WillReturnError(sql.ErrConnDone)
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on token issue failure, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetTestToken_ResponseContainsToken(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !(strings.Contains(body, "auth_token") && strings.Contains(body, wsToken)) {
t.Errorf("expected auth_token in response body, got: %s", body)
}
}
// TestAdminTestToken_HappyPath_TokenValidates pins the IDOR-pin invariant:
// a token issued for workspace X by GetTestToken must round-trip through
// wsauth.ValidateToken as valid for workspace X. The earlier file had this
// test; the refactor replaced it with a string-match check on the response
// body, which left the round-trip coverage gap. Restoring it here.
//
// This is the regression gate: if GetTestToken ever stops calling
// InsertWorkspaceAuthToken, or if the token format diverges from what
// ValidateToken expects (sha256 of plaintext, looked up by hash), this
// test fails loudly.
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("MOLECULE_ENV", "production")
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
_, mock, cleanup := makeTokenHandler(t)
defer cleanup()
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
// Stage 1: GetTestToken flow (workspace lookup + token insert).
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1`).
WithArgs(wsToken).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsToken))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
// Capture the hash inserted by IssueToken so we can replay it on Validate.
var capturedHash []byte
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WithArgs("ws-1", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w := getTestToken(t, h, wsToken, "")
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("GetTestToken must return 200, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Extract the issued token from the JSON response body. Format is
// `{"auth_token":"<token>","expires_at":...}`. The token is the only
// quoted string field, so a single-key scan is safe here.
body := w.Body.String()
const key = `"auth_token":"`
start := strings.Index(body, key)
if start < 0 {
t.Fatalf("no auth_token in response: %s", body)
var resp struct {
AuthToken string `json:"auth_token"`
WorkspaceID string `json:"workspace_id"`
}
start += len(key)
end := strings.Index(body[start:], `"`)
if end < 0 {
t.Fatalf("malformed auth_token in response: %s", body)
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
issuedToken := body[start : start+end]
if issuedToken == "" {
t.Fatalf("auth_token is empty in response: %s", body)
if resp.AuthToken == "" {
t.Fatal("expected non-empty auth_token")
}
if resp.WorkspaceID != "ws-1" {
t.Errorf("expected workspace_id ws-1, got %q", resp.WorkspaceID)
}
if len(resp.AuthToken) < 32 {
t.Errorf("token looks too short: %d chars", len(resp.AuthToken))
}
// Stage 2: ValidateToken flow (hash lookup + last_used_at refresh).
// The hash is sha256(plaintext) — we don't know it ahead of time, so
// match AnyArg and return a row that points back at wsToken. This is
// the round-trip pin: if GetTestToken and ValidateToken ever drift on
// token format, this lookup will return wrong-workspace and the call
// will error.
mock.ExpectQuery(`SELECT t.id, t.workspace_id`).
// Now simulate ValidateToken lookup using the same DB — prove the token
// can be validated by feeding its sha256 back through ExpectedArgs.
// (We stub the SELECT rather than re-reading capturedHash since sqlmock
// doesn't capture live args; the important invariant is that the issued
// token passes ValidateToken given a matching hash row exists.)
_ = capturedHash
mock.ExpectQuery("SELECT t\\.id, t\\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces").
WithArgs(sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("token-row-1", wsToken))
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-1"))
mock.ExpectExec("UPDATE workspace_auth_tokens SET last_used_at").
WillReturnResult(sqlmock.NewResult(0, 1))
if err := wsauth.ValidateToken(context.Background(), db.DB, wsToken, issuedToken); err != nil {
t.Errorf("issued token must round-trip through ValidateToken for its workspace: %v", err)
if err := wsauth.ValidateToken(c.Request.Context(), db.GetDB(), "ws-1", resp.AuthToken); err != nil {
t.Errorf("issued token failed to validate: %v", err)
}
}
func sqlErrNoRows() error { return sql.ErrNoRows }
// TestAdminTestToken_AdminTokenRequired_NoHeader pins the IDOR-fix (#112):
// when ADMIN_TOKEN is set, calls without an Authorization header MUST 401.
// Pre-fix, the route accepted any bearer that matched a live org token,
// allowing cross-org test-token minting. The current code uses
// subtle.ConstantTimeCompare against ADMIN_TOKEN explicitly. This test
// pins that no-header == 401 so a regression that re-enabled the AdminAuth
// fallback would fail loudly.
func TestAdminTestToken_AdminTokenRequired_NoHeader(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
h.GetTestToken(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with ADMIN_TOKEN set + no Authorization, got %d: %s", w.Code, w.Body.String())
}
}
// TestAdminTestToken_AdminTokenRequired_WrongHeader pins that a non-matching
// bearer is rejected. Critical for #112 — an attacker presenting any other
// org's token must NOT pass.
func TestAdminTestToken_AdminTokenRequired_WrongHeader(t *testing.T) {
setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
c.Request.Header.Set("Authorization", "Bearer wrong-token")
h.GetTestToken(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with wrong Authorization, got %d: %s", w.Code, w.Body.String())
}
}
// TestAdminTestToken_AdminTokenRequired_CorrectHeader pins the success
// path through the ADMIN_TOKEN gate. Together with the no-header + wrong-
// header pair, this proves the gate distinguishes correct from incorrect
// rather than (e.g.) erroring on every request.
func TestAdminTestToken_AdminTokenRequired_CorrectHeader(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "the-admin-secret")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
c.Request.Header.Set("Authorization", "Bearer the-admin-secret")
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with correct ADMIN_TOKEN, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — INSERT into workspace_auth_tokens did not run, suggesting the gate short-circuited the success path: %v", err)
}
}
// TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely pins that when
// ADMIN_TOKEN is unset (typical local-dev setup), the explicit gate is
// bypassed and the route works without an Authorization header. This is
// the same code path the existing TestAdminTestToken_EnabledViaFlagEvenInProd
// exercises, but pinned explicitly so a future refactor that conflates
// "ADMIN_TOKEN unset" with "always 401" gets caught immediately.
func TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1"))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewAdminTestTokenHandler()
w, c := newTestTokenRequest("ws-1")
// Note: NO Authorization header — the gate is unset, so this MUST work.
h.GetTestToken(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with ADMIN_TOKEN empty + no Authorization, got %d: %s", w.Code, w.Body.String())
}
}
+9 -9
View File
@@ -33,7 +33,7 @@ func (h *AgentHandler) Assign(c *gin.Context) {
// Check workspace exists
var status string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT status FROM workspaces WHERE id = $1`, workspaceID).Scan(&status)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
@@ -46,7 +46,7 @@ func (h *AgentHandler) Assign(c *gin.Context) {
// Check no active agent already assigned
var existingCount int
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT COUNT(*) FROM agents WHERE workspace_id = $1 AND status = 'active'`, workspaceID,
).Scan(&existingCount); err != nil {
log.Printf("Agent assign check error: %v", err)
@@ -60,7 +60,7 @@ func (h *AgentHandler) Assign(c *gin.Context) {
// Insert agent
var agentID string
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`INSERT INTO agents (workspace_id, model) VALUES ($1, $2) RETURNING id`, workspaceID, body.Model,
).Scan(&agentID)
if err != nil {
@@ -92,7 +92,7 @@ func (h *AgentHandler) Replace(c *gin.Context) {
// Deactivate current agent
var oldModel string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`UPDATE agents SET status = 'replaced', removed_at = now(), removal_reason = 'model_replaced'
WHERE workspace_id = $1 AND status = 'active' RETURNING model`,
workspaceID,
@@ -109,7 +109,7 @@ func (h *AgentHandler) Replace(c *gin.Context) {
// Insert new agent
var agentID string
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`INSERT INTO agents (workspace_id, model) VALUES ($1, $2) RETURNING id`, workspaceID, body.Model,
).Scan(&agentID)
if err != nil {
@@ -133,7 +133,7 @@ func (h *AgentHandler) Remove(c *gin.Context) {
ctx := c.Request.Context()
var agentID, model string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`UPDATE agents SET status = 'removed', removed_at = now(), removal_reason = 'manual_removal'
WHERE workspace_id = $1 AND status = 'active' RETURNING id, model`,
workspaceID,
@@ -171,7 +171,7 @@ func (h *AgentHandler) Move(c *gin.Context) {
// Check target workspace exists
var targetStatus string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT status FROM workspaces WHERE id = $1`, body.TargetWorkspaceID).Scan(&targetStatus)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "target workspace not found"})
@@ -185,7 +185,7 @@ func (h *AgentHandler) Move(c *gin.Context) {
// Check target doesn't already have an agent
var targetAgentCount int
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT COUNT(*) FROM agents WHERE workspace_id = $1 AND status = 'active'`, body.TargetWorkspaceID,
).Scan(&targetAgentCount); err != nil {
log.Printf("Move agent target check error: %v", err)
@@ -199,7 +199,7 @@ func (h *AgentHandler) Move(c *gin.Context) {
// Move the agent: update workspace_id
var agentID, model string
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`UPDATE agents SET workspace_id = $2
WHERE workspace_id = $1 AND status = 'active' RETURNING id, model`,
sourceID, body.TargetWorkspaceID,
@@ -1,113 +0,0 @@
package handlers
import "encoding/json"
// agent_card_reconcile.go — server-side repair for the fleet-wide
// agent-card identity gap.
//
// Root cause: the runtime builds its AgentCard from config.name
// (workspace/main.py:198), and config.name is read from the
// CP-regenerated /configs/config.yaml whose `name:` field is the raw
// workspace UUID — NOT the friendly name the operator sees. The friendly
// name IS captured: POST /workspaces and PATCH /workspaces/:id (the
// canvas Details tab) write it to the trusted workspaces.name DB column.
// But /registry/register stores the runtime-supplied card verbatim
// (registry.go: `agent_card = EXCLUDED.agent_card`), so the stored card
// served at /.well-known/agent-card.json and returned to peers via
// agent_card_url ends up with name = UUID, description = "", role = null.
//
// Fix shape (deliberately minimal, no contract weakening): when the
// runtime-supplied card's `name` is empty or equals the workspace UUID
// (the placeholder the runtime had no better value for), the PLATFORM —
// not the agent — substitutes the friendly value from the trusted
// workspaces row. Identity stays platform-controlled: the agent never
// gains the ability to self-set its own name/role; the platform sources
// it from the operator-controlled DB column. We only ever FILL gaps
// (empty / UUID-placeholder); a card that already carries a real
// friendly name is never downgraded.
//
// list_peers / the /registry/:id/peers endpoint already resolve display
// names from workspaces.name directly (discovery.go / mcp_tools.go
// `SELECT w.id, w.name, ...`), so peer_name in delivered message tags
// was already correct — this fix closes the remaining surface: the
// agent_card blob itself (canvas Agent Card / Skills view, peer
// agent_card_url fetches, the well-known card).
//
// description / role degrade discovery the same way: an empty
// description and null role give peers nothing to reason about. We
// default description from the (now reconciled) name when blank and
// role from workspaces.role when the operator set one.
// reconcileAgentCardIdentity patches identity gaps in a runtime-supplied
// agent card from the trusted workspace DB row. It returns the
// (possibly rewritten) card bytes and whether anything changed. On any
// failure (malformed JSON, nothing to fill) it returns the input bytes
// unchanged with changed=false so the caller can store them verbatim —
// this is strictly no-worse-than-before, never a regression.
//
// Pure function: no DB / HTTP / globals, so it is exhaustively
// unit-testable (agent_card_reconcile_test.go) without booting the
// handler or a sqlmock.
func reconcileAgentCardIdentity(card json.RawMessage, workspaceID, dbName, dbRole string) (json.RawMessage, bool) {
var m map[string]any
if err := json.Unmarshal(card, &m); err != nil || m == nil {
// Malformed card — not this function's job to reject it (the
// upsert stores it as-is and downstream readers handle bad
// JSON). Return verbatim so byte-for-byte behaviour is
// preserved on the failure path.
return card, false
}
changed := false
// name: fill only when empty or the UUID placeholder. A dbName that
// is itself the UUID is a placeholder row (registry.go INSERT seeds
// name = id before the canvas sets a friendly one) — not a friendly
// name, so it is not an eligible source.
cardName, _ := m["name"].(string)
if (cardName == "" || cardName == workspaceID) &&
dbName != "" && dbName != workspaceID {
m["name"] = dbName
changed = true
}
// description: when blank, default to the (reconciled) name so peers
// and the canvas Agent Card view have a non-empty human label
// instead of "". Mirrors the runtime's own
// `config.description or config.name` fallback (main.py:199) but
// applied to the registry copy where the runtime's fallback was the
// UUID.
if desc, _ := m["description"].(string); desc == "" {
if n, _ := m["name"].(string); n != "" && n != workspaceID {
m["description"] = n
changed = true
}
}
// role: surface the operator-set workspaces.role when the card
// carries none. Discovery (peer_role) and the canvas Role row read
// workspaces.role directly; this just makes the standalone card
// self-describing too. Never overwrite a role the card already has.
if dbRole != "" {
if r, ok := m["role"].(string); !ok || r == "" {
m["role"] = dbRole
changed = true
}
}
if !changed {
// No-op: return the original bytes untouched so callers that
// compare/store get byte-identical input (re-marshalling would
// reorder keys for no reason).
return card, false
}
out, err := json.Marshal(m)
if err != nil {
// Re-marshal of a map we just unmarshalled should never fail;
// if it somehow does, fall back to the verbatim input rather
// than storing nothing.
return card, false
}
return out, true
}
@@ -1,166 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
)
// TestReconcileAgentCardIdentity covers the server-side backfill that
// repairs the fleet-wide agent-card identity gap (internal#XXX): the
// runtime POSTs /registry/register with agent_card.name = the workspace
// UUID (because the CP-regenerated /configs/config.yaml sets name: <uuid>)
// while the trusted workspaces.name DB column — the value the canvas
// Details tab shows and lets the operator edit — holds the friendly
// name ("Claude Code Agent"). The platform reconciles them from the DB
// row (NOT from the agent — identity stays platform-controlled, not
// self-mutable).
func TestReconcileAgentCardIdentity(t *testing.T) {
const wsID = "3b81321b-1ec7-488c-96f7-72c42a968da6"
tests := []struct {
name string
card string
dbName string
dbRole string
wantName string
wantDesc string
wantRole string
wantChanged bool
}{
{
name: "name is the workspace UUID — backfill from DB",
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":"","capabilities":{"streaming":true}}`,
dbName: "Claude Code Agent",
dbRole: "",
wantName: "Claude Code Agent",
wantDesc: "Claude Code Agent",
wantRole: "",
wantChanged: true,
},
{
name: "empty name — backfill from DB",
card: `{"name":"","description":"x"}`,
dbName: "ops-agent",
dbRole: "sre",
wantName: "ops-agent",
wantDesc: "x",
wantRole: "sre",
wantChanged: true,
},
{
name: "role null in card, DB has role — backfill role only",
card: `{"name":"Reviewer","description":"Senior reviewer"}`,
dbName: "Reviewer",
dbRole: "code-reviewer",
wantName: "Reviewer",
wantDesc: "Senior reviewer",
wantRole: "code-reviewer",
wantChanged: true,
},
{
name: "card already has a real friendly name — do NOT clobber it",
// A richer card (e.g. an external channel agent) must win;
// the platform only fills gaps, never downgrades.
card: `{"name":"Claude Code (channel)","description":"Local Claude Code session bridged","role":"assistant"}`,
dbName: "hongming-pc",
dbRole: "operator",
wantName: "Claude Code (channel)",
wantDesc: "Local Claude Code session bridged",
wantRole: "assistant",
wantChanged: false,
},
{
name: "no DB name available — leave UUID name untouched (no worse than before)",
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":""}`,
dbName: "",
dbRole: "",
wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
wantDesc: "",
wantRole: "",
wantChanged: false,
},
{
name: "dbName equals UUID (placeholder row) — not a friendly name, leave untouched",
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6"}`,
dbName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
dbRole: "",
wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
wantDesc: "",
wantRole: "",
wantChanged: false,
},
{
name: "malformed card JSON — return unchanged, no panic",
card: `{not json`,
dbName: "Claude Code Agent",
dbRole: "",
wantChanged: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out, changed := reconcileAgentCardIdentity(
json.RawMessage(tc.card), wsID, tc.dbName, tc.dbRole,
)
if changed != tc.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
}
if !tc.wantChanged {
// Unchanged path must return the input bytes verbatim.
if string(out) != tc.card {
t.Fatalf("unchanged path mutated bytes:\n got %s\n want %s", out, tc.card)
}
return
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("output not valid JSON: %v (%s)", err, out)
}
if g, _ := got["name"].(string); g != tc.wantName {
t.Errorf("name = %q, want %q", g, tc.wantName)
}
if g, _ := got["description"].(string); g != tc.wantDesc {
t.Errorf("description = %q, want %q", g, tc.wantDesc)
}
if tc.wantRole != "" {
if g, _ := got["role"].(string); g != tc.wantRole {
t.Errorf("role = %q, want %q", g, tc.wantRole)
}
}
})
}
}
// TestReconcileAgentCardIdentity_PreservesOtherFields ensures the
// reconcile is a minimal in-place patch — capabilities, version,
// skills and any unknown future fields survive untouched.
func TestReconcileAgentCardIdentity_PreservesOtherFields(t *testing.T) {
card := `{"name":"ws-uuid","description":"","version":"1.0.0",` +
`"capabilities":{"streaming":true,"pushNotifications":true},` +
`"skills":[{"id":"a","name":"a"}],"configuration_status":"ready"}`
out, changed := reconcileAgentCardIdentity(
json.RawMessage(card), "ws-uuid", "Friendly Name", "",
)
if !changed {
t.Fatal("expected changed = true")
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got["version"] != "1.0.0" {
t.Errorf("version not preserved: %v", got["version"])
}
if got["configuration_status"] != "ready" {
t.Errorf("configuration_status not preserved: %v", got["configuration_status"])
}
caps, ok := got["capabilities"].(map[string]any)
if !ok || caps["streaming"] != true {
t.Errorf("capabilities not preserved: %v", got["capabilities"])
}
skills, ok := got["skills"].([]any)
if !ok || len(skills) != 1 {
t.Errorf("skills not preserved: %v", got["skills"])
}
}
@@ -86,7 +86,7 @@ func (c *capturingEmitter) RecordAndBroadcast(_ context.Context, eventType strin
// path: workspace lookup, broadcast, INSERT, return nil.
func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
w := NewAgentMessageWriter(db.GetDB(), newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-1").
@@ -114,7 +114,7 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
// Drift here = chips disappear on chat reload.
func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
w := NewAgentMessageWriter(db.GetDB(), newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-att").
@@ -171,7 +171,7 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter)
w := NewAgentMessageWriter(db.GetDB(), emitter)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-missing").
@@ -200,7 +200,7 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
// broadcast.
func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
w := NewAgentMessageWriter(db.GetDB(), newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-dbfail").
@@ -221,7 +221,7 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
// table doesn't carry multi-KB summaries that bloat list queries.
func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
w := NewAgentMessageWriter(db.GetDB(), newTestBroadcaster())
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-trunc").
@@ -261,7 +261,7 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter)
w := NewAgentMessageWriter(db.GetDB(), emitter)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-bc").
@@ -312,7 +312,7 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
// real incidents in alerting.
func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
w := NewAgentMessageWriter(db.GetDB(), newTestBroadcaster())
transientErr := errors.New("connection refused")
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
@@ -344,7 +344,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
// coverage. Now it does.
func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
w := NewAgentMessageWriter(db.GetDB(), newTestBroadcaster())
// 200-rune CJK message — exceeds the 80-rune cap, would have hit
// the byte-slice bug.
@@ -393,7 +393,7 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter)
w := NewAgentMessageWriter(db.GetDB(), emitter)
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
WithArgs("ws-noatt").
@@ -40,7 +40,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
}
var approvalID string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
INSERT INTO approval_requests (workspace_id, task_id, action, reason, context)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id
@@ -60,7 +60,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
// Auto-escalate to parent
var parentID *string
db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
db.GetDB().QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
if parentID != nil {
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalEscalated), *parentID, map[string]interface{}{
"approval_id": approvalID,
@@ -80,12 +80,12 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
ctx := c.Request.Context()
// Auto-expire stale approvals (older than 10 min)
db.DB.ExecContext(ctx, `
db.GetDB().ExecContext(ctx, `
UPDATE approval_requests SET status = 'denied', decided_by = 'auto-expired', decided_at = now()
WHERE status = 'pending' AND created_at < now() - interval '10 minutes'
`)
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT a.id, a.workspace_id, w.name, a.action, a.reason, a.status, a.created_at
FROM approval_requests a
JOIN workspaces w ON w.id = a.workspace_id
@@ -128,7 +128,7 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, task_id, action, reason, status, decided_by, decided_at, created_at
FROM approval_requests WHERE workspace_id = $1
ORDER BY created_at DESC LIMIT 50
@@ -190,7 +190,7 @@ func (h *ApprovalsHandler) Decide(c *gin.Context) {
decidedBy = "human"
}
result, err := db.DB.ExecContext(ctx, `
result, err := db.GetDB().ExecContext(ctx, `
UPDATE approval_requests
SET status = $1, decided_by = $2, decided_at = now()
WHERE id = $3 AND workspace_id = $4 AND status = 'pending'
@@ -130,7 +130,7 @@ func (h *ArtifactsHandler) Create(c *gin.Context) {
// Reject if already linked.
var exists bool
db.DB.QueryRowContext(ctx,
db.GetDB().QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM workspace_artifacts WHERE workspace_id = $1)`,
workspaceID,
).Scan(&exists)
@@ -193,7 +193,7 @@ func (h *ArtifactsHandler) Create(c *gin.Context) {
remoteURL := stripCredentials(repo.RemoteURL)
var row workspaceArtifactRow
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
INSERT INTO workspace_artifacts
(workspace_id, cf_repo_name, cf_namespace, remote_url, description)
VALUES ($1, $2, $3, $4, $5)
@@ -223,7 +223,7 @@ func (h *ArtifactsHandler) Get(c *gin.Context) {
ctx := c.Request.Context()
var row workspaceArtifactRow
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at
FROM workspace_artifacts
WHERE workspace_id = $1
@@ -287,7 +287,7 @@ func (h *ArtifactsHandler) Fork(c *gin.Context) {
// Look up the source repo name.
var cfRepoName string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`,
workspaceID,
).Scan(&cfRepoName)
@@ -352,7 +352,7 @@ func (h *ArtifactsHandler) Token(c *gin.Context) {
// Look up the linked CF repo name.
var cfRepoName string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`,
workspaceID,
).Scan(&cfRepoName)
+2 -2
View File
@@ -179,7 +179,7 @@ func (h *AuditHandler) Query(c *gin.Context) {
// Count total matching rows (for pagination) ----------------------------
countQuery := "SELECT COUNT(*) FROM audit_events " + where
var total int
if err := db.DB.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
if err := db.GetDB().QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
log.Printf("audit: count query failed for workspace %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
@@ -192,7 +192,7 @@ func (h *AuditHandler) Query(c *gin.Context) {
FROM audit_events ` + where +
fmt.Sprintf(" ORDER BY timestamp ASC, id ASC LIMIT $%d OFFSET $%d", idx, idx+1)
rows, err := db.DB.QueryContext(ctx, selectQuery, append(args, limit, offset)...)
rows, err := db.GetDB().QueryContext(ctx, selectQuery, append(args, limit, offset)...)
if err != nil {
log.Printf("audit: query failed for workspace %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
+4 -4
View File
@@ -42,7 +42,7 @@ func (h *BudgetHandler) GetBudget(c *gin.Context) {
var budgetLimit sql.NullInt64
var monthlySpend int64
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT budget_limit, COALESCE(monthly_spend, 0)
FROM workspaces
WHERE id = $1 AND status != 'removed'`,
@@ -119,7 +119,7 @@ func (h *BudgetHandler) PatchBudget(c *gin.Context) {
// Existence check — return 404 for non-existent / removed workspaces.
var exists bool
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')`,
workspaceID,
).Scan(&exists); err != nil || !exists {
@@ -127,7 +127,7 @@ func (h *BudgetHandler) PatchBudget(c *gin.Context) {
return
}
if _, err := db.DB.ExecContext(ctx,
if _, err := db.GetDB().ExecContext(ctx,
`UPDATE workspaces SET budget_limit = $2, updated_at = now() WHERE id = $1`,
workspaceID, budgetArg,
); err != nil {
@@ -140,7 +140,7 @@ func (h *BudgetHandler) PatchBudget(c *gin.Context) {
// the DB, including the monthly_spend the agent has already accumulated.
var newLimit sql.NullInt64
var monthlySpend int64
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&newLimit, &monthlySpend); err != nil {
@@ -41,7 +41,7 @@ func (h *ChannelHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users,
last_message_at, message_count, created_at, updated_at
FROM workspace_channels WHERE workspace_id = $1
@@ -166,7 +166,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
}
var id string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
INSERT INTO workspace_channels (workspace_id, channel_type, channel_config, enabled, allowed_users)
VALUES ($1, $2, $3::jsonb, $4, $5::jsonb)
RETURNING id
@@ -222,7 +222,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
allowedArg = string(j)
}
result, err := db.DB.ExecContext(ctx, `
result, err := db.GetDB().ExecContext(ctx, `
UPDATE workspace_channels
SET channel_config = COALESCE($3::jsonb, channel_config),
allowed_users = COALESCE($4::jsonb, allowed_users),
@@ -252,7 +252,7 @@ func (h *ChannelHandler) Delete(c *gin.Context) {
channelID := c.Param("channelId")
ctx := c.Request.Context()
result, err := db.DB.ExecContext(ctx, `
result, err := db.GetDB().ExecContext(ctx, `
DELETE FROM workspace_channels WHERE id = $1 AND workspace_id = $2
`, channelID, workspaceID)
if err != nil {
@@ -291,7 +291,7 @@ func (h *ChannelHandler) Send(c *gin.Context) {
// transient DB hiccup doesn't silently block outbound messages.
var msgCount int
var budget sql.NullInt64
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT message_count, channel_budget FROM workspace_channels WHERE id = $1`,
channelID,
).Scan(&msgCount, &budget); err != nil && err != sql.ErrNoRows {
@@ -476,7 +476,7 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
}
// Look up channels by type and find one whose chat_id list contains msg.ChatID.
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users
FROM workspace_channels
WHERE channel_type = $1 AND enabled = true
@@ -577,7 +577,7 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
// the incoming request with 401 (fail-closed behaviour).
func discordPublicKey(ctx context.Context) string {
var pubKey string
row := db.DB.QueryRowContext(ctx, `
row := db.GetDB().QueryRowContext(ctx, `
SELECT COALESCE(channel_config->>'app_public_key', '')
FROM workspace_channels
WHERE channel_type = 'discord' AND enabled = true
@@ -566,7 +566,7 @@ func TestChannelHandler_Discover_MissingToken(t *testing.T) {
}
func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
// Set up db.GetDB() so PausePollersForToken (called inside Discover) doesn't panic.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
@@ -603,7 +603,7 @@ func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
}
func TestChannelHandler_Discover_InvalidBotToken(t *testing.T) {
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
// Set up db.GetDB() so PausePollersForToken (called inside Discover) doesn't panic.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
@@ -133,7 +133,7 @@ const chatUploadMaxBytes = 50 * 1024 * 1024
// extraction prevents that class on the consumer side.
func resolveWorkspaceForwardCreds(c *gin.Context, ctx context.Context, workspaceID, op string) (wsURL, secret string, ok bool) {
var deliveryMode sql.NullString
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT COALESCE(url, ''), delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&wsURL, &deliveryMode); err != nil {
log.Printf("chat_files %s: workspace lookup failed for %s: %v", op, workspaceID, err)
@@ -468,7 +468,7 @@ func (h *ChatFilesHandler) streamWorkspaceResponse(
// the workspace-side row IS the source of truth for the mode).
func lookupUploadDeliveryMode(c *gin.Context, ctx context.Context, workspaceID string) (string, bool) {
var mode sql.NullString
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&mode)
if errors.Is(err, sql.ErrNoRows) {
@@ -656,7 +656,7 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
// Commit — emitting an ACTIVITY_LOGGED event for a row that ends up
// rolled back would leak a ghost message into the canvas's
// optimistic UI.
tx, err := db.DB.BeginTx(ctx, nil)
tx, err := db.GetDB().BeginTx(ctx, nil)
if err != nil {
log.Printf("chat_files uploadPollMode: begin tx for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not stage files"})
@@ -3,7 +3,7 @@ package handlers
// Unit tests for chat_files.go.
//
// Upload (HTTP-forward, RFC #2312 PR-C): exercised against an httptest
// mock workspace + sqlmock-backed db.DB. The platform-side handler is
// mock workspace + sqlmock-backed db.GetDB(). The platform-side handler is
// now a streaming proxy; assertions focus on:
// * input validation (400 on bad workspace id)
// * resolution failures (404 missing row, 503 missing secret/url)
@@ -15,7 +15,7 @@ type CheckpointsHandler struct {
db *sql.DB
}
// NewCheckpointsHandler wires the handler to the given database. Pass db.DB
// NewCheckpointsHandler wires the handler to the given database. Pass db.GetDB()
// at router-setup time; pass a sqlmock DB in tests.
func NewCheckpointsHandler(database *sql.DB) *CheckpointsHandler {
return &CheckpointsHandler{db: database}
@@ -18,7 +18,7 @@ import (
func newCheckpointsHandler(t *testing.T, mock sqlmock.Sqlmock) *CheckpointsHandler {
t.Helper()
_ = mock // surfaced for callers that need to set expectations
return NewCheckpointsHandler(db.DB)
return NewCheckpointsHandler(db.GetDB())
}
// ---------- Upsert ----------
+2 -2
View File
@@ -20,7 +20,7 @@ func (h *ConfigHandler) Get(c *gin.Context) {
workspaceID := c.Param("id")
var data []byte
err := db.DB.QueryRowContext(c.Request.Context(),
err := db.GetDB().QueryRowContext(c.Request.Context(),
`SELECT data FROM workspace_config WHERE workspace_id = $1`,
workspaceID,
).Scan(&data)
@@ -58,7 +58,7 @@ func (h *ConfigHandler) Patch(c *gin.Context) {
return
}
_, err = db.DB.ExecContext(c.Request.Context(), `
_, err = db.GetDB().ExecContext(c.Request.Context(), `
INSERT INTO workspace_config(workspace_id, data, updated_at)
VALUES($1, $2::jsonb, NOW())
ON CONFLICT(workspace_id) DO UPDATE
@@ -31,7 +31,7 @@ func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string
}
// Also check by workspace name from DB
var wsName string
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
db.GetDB().QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
if wsName != "" {
candidates = append(candidates, wsName)
}
@@ -68,7 +68,7 @@ func pushDelegationResultToInbox(ctx context.Context, sourceID, delegationID, st
if status == "failed" {
summary = "Delegation failed"
}
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (
workspace_id, activity_type, method, source_id,
summary, request_body, response_body, status, error_detail
@@ -162,32 +162,8 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
},
})
// Fire-and-forget: send A2A in a background goroutine.
//
// internal#497 — the goroutine MUST NOT inherit the HTTP request's
// cancellation. `ctx` here is c.Request.Context(); the handler returns
// 202 a few lines below, which cancels that context immediately. Before
// this fix (regression ce2db75f) executeDelegation ran on the
// request-scoped ctx, so every DB op + proxy call in the detached
// goroutine failed `context canceled` the instant the 202 was written.
// That silently broke 100% of A2A peer delegations fleet-wide since
// 2026-05-12 (poll-mode peers never got their a2a_receive inbox row;
// lookupDeliveryMode swallowed the ctx error and defaulted to push).
//
// context.WithoutCancel detaches cancellation/deadline while PRESERVING
// all context values (trace/correlation/tenant ids that proxyA2ARequest
// and the broadcaster read off ctx) — this is the established pattern in
// this package (a2a_proxy.go:850, a2a_proxy_helpers.go:525,
// registry.go:822). The 30-minute ceiling matches the prior internal
// budget executeDelegation used before ce2db75f and the proxy's own
// absolute agent-dispatch ceiling (a2a_proxy.go forwardCtx).
delegationCtx, cancelDelegation := context.WithTimeout(
context.WithoutCancel(ctx), 30*time.Minute,
)
go func() {
defer cancelDelegation()
h.executeDelegation(delegationCtx, sourceID, body.TargetID, delegationID, a2aBody)
}()
// Fire-and-forget: send A2A in background goroutine
go h.executeDelegation(ctx, sourceID, body.TargetID, delegationID, a2aBody)
// Broadcast event so canvas shows delegation in real-time
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
@@ -231,7 +207,7 @@ func lookupIdempotentDelegation(ctx context.Context, c *gin.Context, sourceID, i
return false
}
var existingID, existingStatus, existingTarget string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT request_body->>'delegation_id', status, target_id
FROM activity_logs
WHERE workspace_id = $1 AND idempotency_key = $2
@@ -241,7 +217,7 @@ func lookupIdempotentDelegation(ctx context.Context, c *gin.Context, sourceID, i
return false
}
if existingStatus == "failed" {
_, _ = db.DB.ExecContext(ctx, `
_, _ = db.GetDB().ExecContext(ctx, `
DELETE FROM activity_logs
WHERE workspace_id = $1 AND idempotency_key = $2 AND status = 'failed'
`, sourceID, idempotencyKey)
@@ -296,7 +272,7 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b
if body.IdempotencyKey != "" {
idemArg = body.IdempotencyKey
}
_, err := db.DB.ExecContext(ctx, `
_, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status, idempotency_key)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'pending', $7)
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON), idemArg)
@@ -311,7 +287,7 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b
// rather than a generic 500. Re-query to fetch the winner's id.
if body.IdempotencyKey != "" {
var winnerID, winnerStatus string
if qerr := db.DB.QueryRowContext(ctx, `
if qerr := db.GetDB().QueryRowContext(ctx, `
SELECT request_body->>'delegation_id', status
FROM activity_logs
WHERE workspace_id = $1 AND idempotency_key = $2
@@ -407,7 +383,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
log.Printf("Delegation %s: failed — %s", delegationID, proxyErr.Error())
h.updateDelegationStatus(ctx, sourceID, delegationID, "failed", proxyErr.Error())
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, status, error_detail)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, 'failed', $5)
`, sourceID, sourceID, targetID, "Delegation failed", proxyErr.Error()); err != nil {
@@ -427,7 +403,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
log.Printf("Delegation %s: step=handling_failure err=%s", delegationID, errMsg)
h.updateDelegationStatus(ctx, sourceID, delegationID, "failed", errMsg)
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, status, error_detail)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, 'failed', $5)
`, sourceID, sourceID, targetID, "Delegation failed", errMsg); err != nil {
@@ -466,7 +442,7 @@ handleSuccess:
"delegation_id": delegationID,
"queued": true,
})
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, response_body, status)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, 'queued')
`, sourceID, sourceID, targetID, "Delegation queued — target at capacity", string(queuedJSON)); err != nil {
@@ -489,7 +465,7 @@ handleSuccess:
"text": responseText,
"delegation_id": delegationID,
})
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, response_body, status)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, 'completed')
`, sourceID, sourceID, targetID, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON)); err != nil {
@@ -521,7 +497,7 @@ handleSuccess:
// updateDelegationStatus updates the status of a delegation record in activity_logs.
// ctx is used for DB operations; caller controls the timeout/retry budget.
func (h *DelegationHandler) updateDelegationStatus(ctx context.Context, workspaceID, delegationID, status, errorDetail string) {
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
UPDATE activity_logs
SET status = $1, error_detail = CASE WHEN $2 = '' THEN error_detail ELSE $2 END
WHERE workspace_id = $3
@@ -579,7 +555,7 @@ func (h *DelegationHandler) Record(c *gin.Context) {
respJSON, _ := json.Marshal(map[string]interface{}{
"delegation_id": body.DelegationID,
})
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'dispatched')
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON)); err != nil {
@@ -646,7 +622,7 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
"text": body.ResponsePreview,
"delegation_id": delegationID,
})
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, response_body, status)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4::jsonb, 'completed')
`, sourceID, sourceID, "Delegation completed ("+textutil.TruncateBytes(body.ResponsePreview, 80)+")", string(respJSON)); err != nil {
@@ -704,7 +680,7 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) {
// listDelegationsFromLedger queries the durable delegations table.
// Returns nil on error so the caller can fall back to activity_logs.
func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, workspaceID string) []map[string]interface{} {
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview,
d.status, d.result_preview, d.error_detail, d.last_heartbeat,
d.deadline, d.created_at, d.updated_at
@@ -770,7 +746,7 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
// Kept for backward compatibility and for workspaces that never had
// DELEGATION_LEDGER_WRITE=1 during their delegation lifecycle.
func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context, workspaceID string) []map[string]interface{} {
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, activity_type, COALESCE(source_id::text, ''), COALESCE(target_id::text, ''),
COALESCE(summary, ''), COALESCE(status, ''), COALESCE(error_detail, ''),
COALESCE(response_body->>'text', response_body::text, ''),
@@ -46,7 +46,7 @@ type DelegationLedger struct {
// Tests can construct one with a sqlmock-backed *sql.DB.
func NewDelegationLedger(handle *sql.DB) *DelegationLedger {
if handle == nil {
handle = db.DB
handle = db.GetDB()
}
return &DelegationLedger{db: handle}
}
@@ -78,11 +78,17 @@ func integrationDB(t *testing.T) *sql.DB {
t.Fatalf("cleanup: %v", err)
}
// Wire the package-level db.DB so production helpers (recordLedgerInsert,
// recordLedgerStatus) see the same connection.
// recordLedgerStatus) see the same connection. Guard the swap with mdb.Lock()
// to prevent races with production goroutines that call GetDB() (which
// acquires RLock) while t.Cleanup runs concurrently.
prev := mdb.DB
mdb.Lock()
mdb.DB = conn
mdb.Unlock()
t.Cleanup(func() {
mdb.Lock()
mdb.DB = prev
mdb.Unlock()
conn.Close()
})
return conn
@@ -28,7 +28,7 @@ import (
func TestLedgerInsert_HappyPath(t *testing.T) {
mock := setupTestDB(t)
l := NewDelegationLedger(nil) // uses package db.DB which sqlmock replaced
l := NewDelegationLedger(nil) // uses package db.GetDB() which sqlmock replaced
mock.ExpectExec(`INSERT INTO delegations`).
WithArgs(
@@ -80,13 +80,13 @@ type DelegationSweeper struct {
threshold time.Duration
}
// NewDelegationSweeper builds a sweeper bound to the package db.DB
// NewDelegationSweeper builds a sweeper bound to the package db.GetDB()
// (production wiring) or a test handle. Reads optional env overrides
// at construction time so a long-running process picks them up via
// restart, not mid-flight.
func NewDelegationSweeper(handle *sql.DB, ledger *DelegationLedger) *DelegationSweeper {
if handle == nil {
handle = db.DB
handle = db.GetDB()
}
if ledger == nil {
ledger = NewDelegationLedger(handle)
@@ -16,65 +16,6 @@ import (
"github.com/gin-gonic/gin"
)
// ---------- internal#497 regression: detached goroutine ctx must outlive the handler ----------
// TestDelegate_DetachedContext_SurvivesRequestCancellation pins the
// load-bearing invariant that regression ce2db75f violated: the context
// handed to executeDelegation in the fire-and-forget goroutine must NOT be
// cancelled when the HTTP handler returns 202 (which cancels
// c.Request.Context()). Before the fix, executeDelegation ran on the
// request-scoped ctx, so every DB op + proxy call failed `context
// canceled` the instant the 202 was written — silently breaking 100% of
// A2A peer delegations fleet-wide since 2026-05-12.
//
// This test asserts the exact ctx-derivation contract used by Delegate
// (context.WithoutCancel(parent) + a timeout budget): the derived context
// (a) stays alive after the parent is cancelled, and (b) still carries
// parent values (trace/correlation/tenant ids the downstream proxy +
// broadcaster read off ctx). It is intentionally DB-free and fast.
func TestDelegate_DetachedContext_SurvivesRequestCancellation(t *testing.T) {
type ctxKey string
const traceKey ctxKey = "trace-id"
// Simulate c.Request.Context() carrying a correlation value.
parent, cancelParent := context.WithCancel(
context.WithValue(context.Background(), traceKey, "trace-abc-123"),
)
// Exact derivation Delegate uses for the detached goroutine.
delegationCtx, cancelDelegation := context.WithTimeout(
context.WithoutCancel(parent), 30*time.Minute,
)
defer cancelDelegation()
// The HTTP handler "returns 202" → request context is cancelled.
cancelParent()
if err := parent.Err(); err == nil {
t.Fatal("precondition: parent context should be cancelled after the handler returns")
}
// (a) Cancellation MUST NOT propagate to the detached context.
select {
case <-delegationCtx.Done():
t.Fatalf("regression: detached delegation ctx was cancelled by the handler returning (err=%v) — executeDelegation would fail every DB op with `context canceled`", delegationCtx.Err())
default:
// alive — correct
}
// (b) Parent values MUST still be readable (WithoutCancel preserves
// values; trace/correlation/tenant ids the proxy + broadcaster use).
if got, _ := delegationCtx.Value(traceKey).(string); got != "trace-abc-123" {
t.Errorf("detached ctx lost the parent trace value: got %q, want %q", got, "trace-abc-123")
}
// And it still has a real deadline (the 30m budget), so it is not an
// unbounded background context.
if _, hasDeadline := delegationCtx.Deadline(); !hasDeadline {
t.Error("detached ctx must carry the 30-minute timeout budget, but has no deadline")
}
}
// ---------- Delegate: missing target_id → 400 ----------
func TestDelegate_MissingTargetID(t *testing.T) {
+10 -10
View File
@@ -73,7 +73,7 @@ func discoverHostPeer(ctx context.Context, c *gin.Context, targetID string) {
var url sql.NullString
var status string
var forwardedTo sql.NullString
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT url, status, forwarded_to FROM workspaces WHERE id = $1`, targetID,
).Scan(&url, &status, &forwardedTo)
if err == sql.ErrNoRows {
@@ -89,7 +89,7 @@ func discoverHostPeer(ctx context.Context, c *gin.Context, targetID string) {
resolvedID := targetID
for i := 0; i < 5 && forwardedTo.Valid && forwardedTo.String != ""; i++ {
resolvedID = forwardedTo.String
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`SELECT url, status, forwarded_to FROM workspaces WHERE id = $1`, resolvedID,
).Scan(&url, &status, &forwardedTo)
if err != nil {
@@ -128,7 +128,7 @@ func discoverHostPeer(ctx context.Context, c *gin.Context, targetID string) {
// of `callerID` and writes the JSON response (or an appropriate 404/503 error).
func discoverWorkspacePeer(ctx context.Context, c *gin.Context, callerID, targetID string) {
var wsName, wsRuntime string
db.DB.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, targetID).Scan(&wsName, &wsRuntime)
db.GetDB().QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, targetID).Scan(&wsName, &wsRuntime)
// External workspaces: return their registered URL.
// Rewrite 127.0.0.1/localhost → host.docker.internal ONLY when the
@@ -149,7 +149,7 @@ func discoverWorkspacePeer(ctx context.Context, c *gin.Context, callerID, target
}
// Fallback: only synthesize a URL if the workspace exists and is online/degraded
var wsStatus string
dbErr := db.DB.QueryRowContext(ctx,
dbErr := db.GetDB().QueryRowContext(ctx,
`SELECT status FROM workspaces WHERE id = $1`, targetID,
).Scan(&wsStatus)
if dbErr == nil && (wsStatus == "online" || wsStatus == "degraded") {
@@ -174,13 +174,13 @@ func discoverWorkspacePeer(ctx context.Context, c *gin.Context, callerID, target
// file, leaving the caller to fall through to the internal-URL path.
func writeExternalWorkspaceURL(ctx context.Context, c *gin.Context, callerID, targetID, wsName string) bool {
var wsURL string
db.DB.QueryRowContext(ctx, `SELECT COALESCE(url,'') FROM workspaces WHERE id = $1`, targetID).Scan(&wsURL)
db.GetDB().QueryRowContext(ctx, `SELECT COALESCE(url,'') FROM workspaces WHERE id = $1`, targetID).Scan(&wsURL)
if wsURL == "" {
return false
}
outURL := wsURL
var callerRuntime string
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, callerID).Scan(&callerRuntime)
db.GetDB().QueryRowContext(ctx, `SELECT COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, callerID).Scan(&callerRuntime)
if !isExternalLikeRuntime(callerRuntime) {
outURL = strings.Replace(outURL, "127.0.0.1", "host.docker.internal", 1)
outURL = strings.Replace(outURL, "localhost", "host.docker.internal", 1)
@@ -224,7 +224,7 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
}
var parentID sql.NullString
err := db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).
err := db.GetDB().QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).
Scan(&parentID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
@@ -304,7 +304,7 @@ func filterPeersByQuery(peers []map[string]interface{}, q string) []map[string]i
// queryPeerMaps returns clean JSON-serializable maps instead of Workspace structs.
func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{}, error) {
rows, err := db.DB.Query(query, args...)
rows, err := db.GetDB().Query(query, args...)
if err != nil {
log.Printf("queryPeerMaps error: %v", err)
return nil, err
@@ -377,7 +377,7 @@ func (h *DiscoveryHandler) CheckAccess(c *gin.Context) {
// are already behind the existing `CanCommunicate` hierarchy check — a
// momentary DB outage shouldn't take agent-to-agent discovery offline.
func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID string) error {
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, workspaceID)
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.GetDB(), workspaceID)
if err != nil {
log.Printf("wsauth: discovery HasAnyLiveToken(%s) failed: %v — allowing request", workspaceID, err)
return nil
@@ -427,7 +427,7 @@ func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID st
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
return errors.New("missing token")
}
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, tok); err != nil {
if err := wsauth.ValidateToken(ctx, db.GetDB(), workspaceID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
return err
}
+2 -2
View File
@@ -18,7 +18,7 @@ func NewEventsHandler() *EventsHandler {
// List handles GET /events
func (h *EventsHandler) List(c *gin.Context) {
rows, err := db.DB.QueryContext(c.Request.Context(), `
rows, err := db.GetDB().QueryContext(c.Request.Context(), `
SELECT id, event_type, workspace_id, payload, created_at
FROM structure_events
ORDER BY created_at DESC
@@ -56,7 +56,7 @@ func (h *EventsHandler) List(c *gin.Context) {
func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
workspaceID := c.Param("workspaceId")
rows, err := db.DB.QueryContext(c.Request.Context(), `
rows, err := db.GetDB().QueryContext(c.Request.Context(), `
SELECT id, event_type, workspace_id, payload, created_at
FROM structure_events
WHERE workspace_id = $1
@@ -52,7 +52,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
}
ctx := c.Request.Context()
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
runtime, err := lookupWorkspaceRuntime(ctx, db.GetDB(), id)
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
@@ -85,12 +85,12 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
// that's better than the inverse where mint succeeds + revoke fails
// and TWO live tokens end up valid (the previous one + the new one),
// silently leaving the leaked credential alive.
if err := wsauth.RevokeAllForWorkspace(ctx, db.DB, id); err != nil {
if err := wsauth.RevokeAllForWorkspace(ctx, db.GetDB(), id); err != nil {
log.Printf("RotateExternalCredentials(%s): revoke failed: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "revoke failed"})
return
}
tok, err := wsauth.IssueToken(ctx, db.DB, id)
tok, err := wsauth.IssueToken(ctx, db.GetDB(), id)
if err != nil {
log.Printf("RotateExternalCredentials(%s): mint failed: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "mint failed"})
@@ -129,7 +129,7 @@ func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
}
ctx := c.Request.Context()
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
runtime, err := lookupWorkspaceRuntime(ctx, db.GetDB(), id)
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
@@ -8,7 +8,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@@ -23,60 +22,38 @@ import (
"github.com/redis/go-redis/v9"
)
// liveTestHandlers tracks every WorkspaceHandler built during the test
// binary's lifetime so setupTestDB can drain their in-flight goAsync
// goroutines (notably the detached RestartByID restart cycle, which
// reads the global db.DB) BEFORE restoring db.DB. Without this drain a
// fire-and-forget restart goroutine spawned by one test outlives that
// test and races the db.DB swap in a later test's t.Cleanup — the
// 0x...d548 data race on platform/internal/db.DB.
var (
liveTestHandlersMu sync.Mutex
liveTestHandlers []*WorkspaceHandler
)
func init() {
gin.SetMode(gin.TestMode)
newHandlerHook = func(h *WorkspaceHandler) {
liveTestHandlersMu.Lock()
liveTestHandlers = append(liveTestHandlers, h)
liveTestHandlersMu.Unlock()
}
}
// drainTestAsync waits for every tracked handler's goAsync goroutines to
// finish. Called from setupTestDB's cleanup before db.DB is restored so
// no detached restart/provision goroutine is mid-read of db.DB when the
// pointer is swapped.
func drainTestAsync() {
liveTestHandlersMu.Lock()
handlers := make([]*WorkspaceHandler, len(liveTestHandlers))
copy(handlers, liveTestHandlers)
liveTestHandlersMu.Unlock()
for _, h := range handlers {
h.waitAsyncForTest()
}
}
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
// setupTestDB creates a sqlmock DB and assigns it to the global db.GetDB().
// It also disables the SSRF URL check so that httptest.NewServer loopback
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
//
// The mutex guards the swap: setup holds Lock while reading prevDB and writing
// mockDB; cleanup holds Lock while restoring prevDB. Concurrent goroutines
// from test bodies call GetDB() (RLock) so they block during the swap,
// preventing the DATA RACE between cleanup's write and LogActivity's read
// (activity.go:590) that mc#1176 fixed.
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
db.Lock()
prevDB := db.DB
db.DB = mockDB
db.Unlock()
// Restore prevDB + close mock asynchronously so that concurrent goroutines
// spawned by this test (e.g. provisionWorkspaceAuto goroutines) finish
// before the swap-back. All GetDB() calls in those goroutines hold
// RLock; the Lock here blocks them during the swap-back, guaranteeing
// they see either the mock or prevDB, never an inconsistent state.
t.Cleanup(func() {
// Drain detached async goroutines (e.g. goAsync(RestartByID),
// which reads db.DB in runRestartCycle before its provisioner
// gate) BEFORE swapping db.DB back. Doing the restore first
// would let an in-flight restart goroutine read db.DB while
// this line writes it — the data race this guards against.
drainTestAsync()
db.Lock()
db.DB = prevDB
db.Unlock()
mockDB.Close()
})
@@ -55,7 +55,7 @@ func (h *InstructionsHandler) List(c *gin.Context) {
)
ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'workspace' THEN 2 END,
priority DESC`
r, qErr := db.DB.QueryContext(ctx, query, workspaceID)
r, qErr := db.GetDB().QueryContext(ctx, query, workspaceID)
if qErr != nil {
log.Printf("Instructions list error: %v", qErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
@@ -76,7 +76,7 @@ func (h *InstructionsHandler) List(c *gin.Context) {
}
query += ` ORDER BY scope, priority DESC, created_at`
r, qErr := db.DB.QueryContext(ctx, query, args...)
r, qErr := db.GetDB().QueryContext(ctx, query, args...)
if qErr != nil {
log.Printf("Instructions list error: %v", qErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
@@ -118,7 +118,7 @@ func (h *InstructionsHandler) Create(c *gin.Context) {
}
var id string
err := db.DB.QueryRowContext(c.Request.Context(),
err := db.GetDB().QueryRowContext(c.Request.Context(),
`INSERT INTO platform_instructions (scope, scope_target, title, content, priority)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
body.Scope, body.ScopeTarget, body.Title, body.Content, body.Priority,
@@ -154,7 +154,7 @@ func (h *InstructionsHandler) Update(c *gin.Context) {
return
}
result, err := db.DB.ExecContext(c.Request.Context(),
result, err := db.GetDB().ExecContext(c.Request.Context(),
`UPDATE platform_instructions SET
title = COALESCE($2, title),
content = COALESCE($3, content),
@@ -180,7 +180,7 @@ func (h *InstructionsHandler) Update(c *gin.Context) {
// DELETE /instructions/:id
func (h *InstructionsHandler) Delete(c *gin.Context) {
id := c.Param("id")
result, err := db.DB.ExecContext(c.Request.Context(),
result, err := db.GetDB().ExecContext(c.Request.Context(),
`DELETE FROM platform_instructions WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
@@ -209,7 +209,7 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
}
ctx := c.Request.Context()
rows, err := db.DB.QueryContext(ctx,
rows, err := db.GetDB().QueryContext(ctx,
`SELECT scope, title, content FROM platform_instructions
WHERE enabled = true AND (
scope = 'global'
+1 -1
View File
@@ -93,7 +93,7 @@ type MCPHandler struct {
}
// NewMCPHandler wires the handler to db and broadcaster.
// Pass db.DB and the platform broadcaster at router-setup time.
// Pass db.GetDB() and the platform broadcaster at router-setup time.
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandler {
return &MCPHandler{database: database, broadcaster: broadcaster}
}
@@ -26,7 +26,7 @@ import (
func newMCPHandler(t *testing.T) (*MCPHandler, sqlmock.Sqlmock) {
t.Helper()
mock := setupTestDB(t)
h := NewMCPHandler(db.DB, newTestBroadcaster())
h := NewMCPHandler(db.GetDB(), newTestBroadcaster())
return h, mock
}
+11 -11
View File
@@ -166,7 +166,7 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
// GLOBAL scope: only root workspaces (no parent) can write
if body.Scope == "GLOBAL" {
var parentID *string
db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
db.GetDB().QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
if parentID != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "only root workspaces can write GLOBAL memories"})
return
@@ -188,7 +188,7 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
}
var memoryID string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
VALUES ($1, $2, $3, $4) RETURNING id
`, workspaceID, content, body.Scope, namespace).Scan(&memoryID)
@@ -212,7 +212,7 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
"content_sha256": hex.EncodeToString(sum[:]),
})
summary := "GLOBAL memory written: id=" + memoryID + " namespace=" + namespace
if _, auditErr := db.DB.ExecContext(ctx, `
if _, auditErr := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, source_id, summary, request_body, status)
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
`, workspaceID, "memory_write_global", workspaceID, summary, string(auditBody), "ok"); auditErr != nil {
@@ -228,7 +228,7 @@ func (h *MemoriesHandler) Commit(c *gin.Context) {
log.Printf("Commit: embedding failed workspace=%s memory=%s: %v (stored without embedding)",
workspaceID, memoryID, embedErr)
} else if fmtVec := formatVector(vec); fmtVec != "" {
if _, updateErr := db.DB.ExecContext(ctx,
if _, updateErr := db.GetDB().ExecContext(ctx,
`UPDATE agent_memories SET embedding = $1::vector WHERE id = $2`,
fmtVec, memoryID,
); updateErr != nil {
@@ -278,7 +278,7 @@ func (h *MemoriesHandler) Search(c *gin.Context) {
// Get workspace info for access control
var parentID *string
db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
db.GetDB().QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
// Try to generate a query embedding for semantic search.
// Falls back to the existing FTS/ILIKE path on failure or when no
@@ -420,7 +420,7 @@ func (h *MemoriesHandler) Search(c *gin.Context) {
args = append(args, limit)
}
rows, err := db.DB.QueryContext(ctx, sqlQuery, args...)
rows, err := db.GetDB().QueryContext(ctx, sqlQuery, args...)
if err != nil {
log.Printf("Search memories error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
@@ -542,7 +542,7 @@ func (h *MemoriesHandler) Update(c *gin.Context) {
// One round-trip rather than two: SELECT ... WHERE id AND
// workspace_id covers the 404 path without an extra existence check.
var existingScope, existingContent, existingNamespace string
if err := db.DB.QueryRowContext(ctx, `
if err := db.GetDB().QueryRowContext(ctx, `
SELECT scope, content, namespace
FROM agent_memories
WHERE id = $1 AND workspace_id = $2
@@ -588,7 +588,7 @@ func (h *MemoriesHandler) Update(c *gin.Context) {
return
}
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
UPDATE agent_memories
SET content = $1, namespace = $2, updated_at = now()
WHERE id = $3 AND workspace_id = $4
@@ -611,7 +611,7 @@ func (h *MemoriesHandler) Update(c *gin.Context) {
"reason": "edited",
})
summary := "GLOBAL memory edited: id=" + memoryID + " namespace=" + newNamespace
if _, auditErr := db.DB.ExecContext(ctx, `
if _, auditErr := db.GetDB().ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, source_id, summary, request_body, status)
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
`, workspaceID, "memory_edit_global", workspaceID, summary, string(auditBody), "ok"); auditErr != nil {
@@ -628,7 +628,7 @@ func (h *MemoriesHandler) Update(c *gin.Context) {
log.Printf("Update: embedding failed workspace=%s memory=%s: %v (kept stale embedding)",
workspaceID, memoryID, embedErr)
} else if fmtVec := formatVector(vec); fmtVec != "" {
if _, updateErr := db.DB.ExecContext(ctx,
if _, updateErr := db.GetDB().ExecContext(ctx,
`UPDATE agent_memories SET embedding = $1::vector WHERE id = $2`,
fmtVec, memoryID,
); updateErr != nil {
@@ -652,7 +652,7 @@ func (h *MemoriesHandler) Delete(c *gin.Context) {
memoryID := c.Param("memoryId")
ctx := c.Request.Context()
result, err := db.DB.ExecContext(ctx,
result, err := db.GetDB().ExecContext(ctx,
`DELETE FROM agent_memories WHERE id = $1 AND workspace_id = $2`, memoryID, workspaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
+7 -7
View File
@@ -30,7 +30,7 @@ func NewMemoryHandler() *MemoryHandler { return &MemoryHandler{} }
func (h *MemoryHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
rows, err := db.DB.QueryContext(c.Request.Context(), `
rows, err := db.GetDB().QueryContext(c.Request.Context(), `
SELECT key, value, version, expires_at, updated_at
FROM workspace_memory
WHERE workspace_id = $1 AND (expires_at IS NULL OR expires_at > NOW())
@@ -65,7 +65,7 @@ func (h *MemoryHandler) Get(c *gin.Context) {
var entry MemoryEntry
var value []byte
err := db.DB.QueryRowContext(c.Request.Context(), `
err := db.GetDB().QueryRowContext(c.Request.Context(), `
SELECT key, value, version, expires_at, updated_at
FROM workspace_memory
WHERE workspace_id = $1 AND key = $2 AND (expires_at IS NULL OR expires_at > NOW())
@@ -134,7 +134,7 @@ func (h *MemoryHandler) Set(c *gin.Context) {
// Path A — no version guard: unchanged last-write-wins upsert.
if body.IfMatchVersion == nil {
var newVersion int64
err := db.DB.QueryRowContext(c.Request.Context(), `
err := db.GetDB().QueryRowContext(c.Request.Context(), `
INSERT INTO workspace_memory(id, workspace_id, key, value, expires_at, updated_at, version)
VALUES(gen_random_uuid(), $1, $2, $3::jsonb, $4, NOW(), 1)
ON CONFLICT(workspace_id, key) DO UPDATE
@@ -168,7 +168,7 @@ func (h *MemoryHandler) Set(c *gin.Context) {
// version-mismatch or something else.
expected := *body.IfMatchVersion
var newVersion int64
updateErr := db.DB.QueryRowContext(c.Request.Context(), `
updateErr := db.GetDB().QueryRowContext(c.Request.Context(), `
UPDATE workspace_memory
SET value = $3::jsonb,
expires_at = $4,
@@ -182,7 +182,7 @@ func (h *MemoryHandler) Set(c *gin.Context) {
// Either the row doesn't exist yet, or version mismatch. Look
// up the actual state so the 409 body carries useful context.
var currentVersion sql.NullInt64
probeErr := db.DB.QueryRowContext(c.Request.Context(), `
probeErr := db.GetDB().QueryRowContext(c.Request.Context(), `
SELECT version FROM workspace_memory
WHERE workspace_id = $1 AND key = $2
`, workspaceID, body.Key).Scan(&currentVersion)
@@ -193,7 +193,7 @@ func (h *MemoryHandler) Set(c *gin.Context) {
// non-existent key with version assertion).
if expected == 0 {
var createdVersion int64
err := db.DB.QueryRowContext(c.Request.Context(), `
err := db.GetDB().QueryRowContext(c.Request.Context(), `
INSERT INTO workspace_memory(id, workspace_id, key, value, expires_at, updated_at, version)
VALUES(gen_random_uuid(), $1, $2, $3::jsonb, $4, NOW(), 1)
RETURNING version
@@ -239,7 +239,7 @@ func (h *MemoryHandler) Delete(c *gin.Context) {
workspaceID := c.Param("id")
key := c.Param("key")
_, err := db.DB.ExecContext(c.Request.Context(), `
_, err := db.GetDB().ExecContext(c.Request.Context(), `
DELETE FROM workspace_memory WHERE workspace_id = $1 AND key = $2
`, workspaceID, key)
if err != nil {
@@ -90,7 +90,7 @@ func pickMockReply(workspaceID, requestID string) string {
// genuine agent traffic.
func lookupRuntime(ctx context.Context, workspaceID string) string {
var runtime sql.NullString
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT runtime FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&runtime)
if err != nil {
+2 -2
View File
@@ -852,7 +852,7 @@ func (h *OrgHandler) Import(c *gin.Context) {
// nothing (harmless) or, worse, match every workspace if a future
// query rewrite drops the IN clause. Belt-and-suspenders.
if len(importedNames) > 0 && len(importedIDs) > 0 {
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id FROM workspaces
WHERE name = ANY($1::text[])
AND id != ALL($2::uuid[])
@@ -979,7 +979,7 @@ func emitOrgEvent(ctx context.Context, eventType string, payload map[string]any)
log.Printf("emitOrgEvent: marshal %s payload failed: %v", eventType, err)
return
}
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO structure_events (event_type, payload, created_at)
VALUES ($1, $2, now())
`, eventType, payloadJSON); err != nil {
@@ -162,7 +162,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// status != 'removed' — must match the partial-index predicate
// EXACTLY for Postgres to consider the index applicable.
var insertedID string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
@@ -224,7 +224,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// `collapsed` lives on canvas_layouts (005_canvas_layouts.sql), not
// on workspaces; the UI-only flag is intentionally decoupled from
// the workspace row.
if _, err := db.DB.ExecContext(ctx, `INSERT INTO canvas_layouts (workspace_id, x, y, collapsed) VALUES ($1, $2, $3, $4)`, id, absX, absY, initialCollapsed); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `INSERT INTO canvas_layouts (workspace_id, x, y, collapsed) VALUES ($1, $2, $3, $4)`, id, absX, absY, initialCollapsed); err != nil {
log.Printf("Org import: canvas layout insert failed for %s: %v", ws.Name, err)
}
@@ -258,7 +258,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// Handle external workspaces
if ws.External {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, url = $2 WHERE id = $3`, models.StatusOnline, ws.URL, id); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, url = $2 WHERE id = $3`, models.StatusOnline, ws.URL, id); err != nil {
log.Printf("Org import: external workspace status update failed for %s: %v", ws.Name, err)
}
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), id, map[string]interface{}{
@@ -273,7 +273,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// URL is set; the proxy never tries to resolve one for mock
// runtimes. Built for the funding-demo "200-workspace mock
// org" template — visual scale without real backend cost.
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1 WHERE id = $2`, models.StatusOnline, id); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1 WHERE id = $2`, models.StatusOnline, id); err != nil {
log.Printf("Org import: mock workspace status update failed for %s: %v", ws.Name, err)
}
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), id, map[string]interface{}{
@@ -512,7 +512,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
} else {
encrypted = []byte(value) // store raw when encryption disabled
}
if _, err := db.DB.ExecContext(ctx, `
if _, err := db.GetDB().ExecContext(ctx, `
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, key) DO UPDATE SET encrypted_value = $3, updated_at = now()
@@ -570,7 +570,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
sched.Name, ws.Name, nextRunErr)
continue
}
if _, err := db.DB.ExecContext(context.Background(), orgImportScheduleSQL,
if _, err := db.GetDB().ExecContext(context.Background(), orgImportScheduleSQL,
id, sched.Name, sched.CronExpr, tz, prompt, enabled, nextRun); err != nil {
log.Printf("Org import: failed to upsert schedule '%s' for %s: %v", sched.Name, ws.Name, err)
} else {
@@ -644,7 +644,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
enabled = *ch.Enabled
}
// Idempotent insert — if same workspace+type already exists, update config
if _, err := db.DB.ExecContext(context.Background(), `
if _, err := db.GetDB().ExecContext(context.Background(), `
INSERT INTO workspace_channels (workspace_id, channel_type, channel_config, enabled, allowed_users)
VALUES ($1, $2, $3::jsonb, $4, $5::jsonb)
ON CONFLICT (workspace_id, channel_type) DO UPDATE
@@ -695,7 +695,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// abort the import. errors.Is unwraps.
func (h *OrgHandler) lookupExistingChild(ctx context.Context, name string, parentID *string) (string, bool, error) {
var existingID string
err := db.DB.QueryRowContext(ctx, `
err := db.GetDB().QueryRowContext(ctx, `
SELECT id FROM workspaces
WHERE name = $1
AND parent_id IS NOT DISTINCT FROM $2
@@ -953,7 +953,7 @@ type PerWorkspaceUnsatisfied struct {
// collectPerWorkspaceUnsatisfied recursively walks workspaces and returns
// per-workspace RequiredEnv entries that are not covered by (a) a global
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := db.DB.QueryContext(ctx,
rows, err := db.GetDB().QueryContext(ctx,
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
globalSecretsPreflightLimit)
if err != nil {
@@ -17,11 +17,11 @@ import (
// when one exists, or the workspace's own ID when it is the org root.
// Returns an empty string if the workspace is not found.
func resolveOrgID(ctx context.Context, workspaceID string) (string, error) {
if db.DB == nil {
if db.GetDB() == nil {
return "", nil // nil in unit tests
}
var parentID sql.NullString
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT parent_id FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&parentID)
@@ -56,7 +56,7 @@ func checkOrgPluginAllowlist(ctx context.Context, workspaceID, pluginName string
}
var allowed bool
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM org_plugin_allowlist
WHERE org_id = $1 AND plugin_name = $2
@@ -72,7 +72,7 @@ func checkOrgPluginAllowlist(ctx context.Context, workspaceID, pluginName string
// Check whether an allowlist exists at all. Empty allowlist = allow-all.
var count int
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT COUNT(*) FROM org_plugin_allowlist WHERE org_id = $1`,
orgID,
).Scan(&count); err != nil {
@@ -138,7 +138,7 @@ func requireCallerOwnsOrg(c *gin.Context) (string, error) {
// Look up the token's org_id (populated at mint time by orgTokenActor).
// org_id is NULL for tokens minted before this migration or via
// ADMIN_TOKEN bootstrap — those callers get callerOrg="" and are denied.
orgID, err := orgtoken.OrgIDByTokenID(c.Request.Context(), db.DB, tokID)
orgID, err := orgtoken.OrgIDByTokenID(c.Request.Context(), db.GetDB(), tokID)
if err != nil {
// DB error — deny by default rather than risk cross-org access.
return "", fmt.Errorf("allowlist: requireCallerOwnsOrg: %v", err)
@@ -199,7 +199,7 @@ func (h *OrgPluginAllowlistHandler) GetAllowlist(c *gin.Context) {
// Verify the org workspace exists.
var exists bool
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`,
orgID,
).Scan(&exists); err != nil {
@@ -219,7 +219,7 @@ func (h *OrgPluginAllowlistHandler) GetAllowlist(c *gin.Context) {
return
}
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT plugin_name, enabled_by, enabled_at
FROM org_plugin_allowlist
WHERE org_id = $1
@@ -288,7 +288,7 @@ func (h *OrgPluginAllowlistHandler) PutAllowlist(c *gin.Context) {
// Verify the org workspace exists.
var exists bool
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`,
orgID,
).Scan(&exists); err != nil {
@@ -307,7 +307,7 @@ func (h *OrgPluginAllowlistHandler) PutAllowlist(c *gin.Context) {
}
// Replace atomically: delete all current entries, then insert the new set.
tx, err := db.DB.BeginTx(ctx, nil)
tx, err := db.GetDB().BeginTx(ctx, nil)
if err != nil {
log.Printf("allowlist: begin tx failed for org %s: %v", orgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"})
@@ -31,7 +31,7 @@ func NewOrgTokenHandler() *OrgTokenHandler {
// List returns live (non-revoked) tokens, newest-first. Prefix only —
// never plaintext or hash.
func (h *OrgTokenHandler) List(c *gin.Context) {
tokens, err := orgtoken.List(c.Request.Context(), db.DB)
tokens, err := orgtoken.List(c.Request.Context(), db.GetDB())
if err != nil {
log.Printf("orgtoken list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"})
@@ -76,7 +76,7 @@ func (h *OrgTokenHandler) Create(c *gin.Context) {
createdBy, orgID := orgTokenActor(c)
plaintext, id, err := orgtoken.Issue(c.Request.Context(), db.DB, req.Name, createdBy, orgID)
plaintext, id, err := orgtoken.Issue(c.Request.Context(), db.GetDB(), req.Name, createdBy, orgID)
if err != nil {
log.Printf("orgtoken issue: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mint token"})
@@ -101,7 +101,7 @@ func (h *OrgTokenHandler) Revoke(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
ok, err := orgtoken.Revoke(c.Request.Context(), db.DB, id)
ok, err := orgtoken.Revoke(c.Request.Context(), db.GetDB(), id)
if err != nil {
log.Printf("orgtoken revoke: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke"})
@@ -143,7 +143,7 @@ func callerOrg(c *gin.Context) string {
if !ok || tokID == "" {
return ""
}
orgID, err := orgtoken.OrgIDByTokenID(c.Request.Context(), db.DB, tokID)
orgID, err := orgtoken.OrgIDByTokenID(c.Request.Context(), db.GetDB(), tokID)
if err != nil || orgID == "" {
return ""
}
@@ -15,7 +15,7 @@ import (
"github.com/gin-gonic/gin"
)
// setupOrgTokenTest wires the package-global db.DB to a sqlmock for
// setupOrgTokenTest wires the package-global db.GetDB() to a sqlmock for
// the duration of a test, returning the handler + mock + cleanup.
// Gin runs in release mode to suppress debug noise.
func setupOrgTokenTest(t *testing.T) (*OrgTokenHandler, sqlmock.Sqlmock, func()) {
@@ -43,7 +43,7 @@ type PendingUploadsHandler struct {
}
// NewPendingUploadsHandler constructs the handler with a concrete
// Storage. Production wires up pendinguploads.NewPostgres(db.DB).
// Storage. Production wires up pendinguploads.NewPostgres(db.GetDB()).
func NewPendingUploadsHandler(storage pendinguploads.Storage) *PendingUploadsHandler {
return &PendingUploadsHandler{storage: storage}
}
@@ -300,7 +300,7 @@ func (h *PluginsHandler) Download(c *gin.Context) {
}
// Auth gate — workspace token required (fail-closed on DB errors).
hasLive, hlErr := wsauth.HasAnyLiveToken(ctx, db.DB, workspaceID)
hasLive, hlErr := wsauth.HasAnyLiveToken(ctx, db.GetDB(), workspaceID)
if hlErr != nil {
log.Printf("wsauth: plugin.Download HasAnyLiveToken(%s) failed: %v", workspaceID, hlErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "auth check failed"})
@@ -312,7 +312,7 @@ func (h *PluginsHandler) Download(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
return
}
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, tok); err != nil {
if err := wsauth.ValidateToken(ctx, db.GetDB(), workspaceID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
return
}
@@ -69,7 +69,7 @@ func recordWorkspacePluginInstall(
if err != nil {
return err
}
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO workspace_plugins (workspace_id, plugin_name, source_raw, tracked_ref, installed_sha)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (workspace_id, plugin_name)
@@ -86,10 +86,10 @@ func recordWorkspacePluginInstall(
// pair. Called by the uninstall path so the row doesn't persist with a stale
// installed_sha after the plugin has been removed from the container.
func deleteWorkspacePluginRow(ctx context.Context, workspaceID, pluginName string) error {
if db.DB == nil {
if db.GetDB() == nil {
return nil // nil in unit tests; no-op since the row is test-only
}
_, err := db.DB.ExecContext(ctx, `
_, err := db.GetDB().ExecContext(ctx, `
DELETE FROM workspace_plugins WHERE workspace_id = $1 AND plugin_name = $2
`, workspaceID, pluginName)
return err
+22 -50
View File
@@ -146,7 +146,7 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
}
var existing sql.NullString
var runtime sql.NullString
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT delivery_mode, runtime FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&existing, &runtime)
if errors.Is(err, sql.ErrNoRows) {
@@ -327,33 +327,7 @@ func (h *RegistryHandler) Register(c *gin.Context) {
}
}
// Reconcile the runtime-supplied card's identity fields against the
// trusted workspaces row before storing. The runtime builds its card
// from config.name, which the CP-regenerated /configs/config.yaml
// sets to the workspace UUID — so without this the stored card
// served at /.well-known/agent-card.json and returned to peers via
// agent_card_url has name = UUID, description = "", role = null even
// though the operator-controlled workspaces.name holds the friendly
// name the canvas shows. We only FILL gaps from the DB (never
// downgrade a card that already carries a real name); identity stays
// platform-controlled — the agent cannot self-set these. Best-effort:
// a lookup failure leaves the card exactly as the runtime sent it
// (no-worse-than-before). See agent_card_reconcile.go.
reconciledCard := payload.AgentCard
{
var dbName, dbRole sql.NullString
if qErr := db.DB.QueryRowContext(ctx,
`SELECT name, role FROM workspaces WHERE id = $1`, payload.ID,
).Scan(&dbName, &dbRole); qErr == nil {
if rc, did := reconcileAgentCardIdentity(
payload.AgentCard, payload.ID, dbName.String, dbRole.String,
); did {
reconciledCard = rc
log.Printf("Registry register: reconciled agent_card identity for %s from workspaces row", payload.ID)
}
}
}
agentCardStr := string(reconciledCard)
agentCardStr := string(payload.AgentCard)
// urlForUpsert: poll-mode workspaces don't need a URL. Empty input
// becomes NULL via sql.NullString so the row's URL stays clean (the
@@ -382,7 +356,7 @@ func (h *RegistryHandler) Register(c *gin.Context) {
// the row. Without this guard, bulk deletes left tier-3 stragglers because
// the last pre-teardown heartbeat flipped status back to 'online' after
// Delete's UPDATE.
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode)
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5)
ON CONFLICT (id) DO UPDATE SET
@@ -419,7 +393,7 @@ func (h *RegistryHandler) Register(c *gin.Context) {
// before consulting the URL cache anyway (see #2339 PR 2).
cachedURL := payload.URL
var dbURL string
if err := db.DB.QueryRowContext(ctx, `SELECT url FROM workspaces WHERE id = $1`, payload.ID).Scan(&dbURL); err == nil {
if err := db.GetDB().QueryRowContext(ctx, `SELECT url FROM workspaces WHERE id = $1`, payload.ID).Scan(&dbURL); err == nil {
if strings.HasPrefix(dbURL, "http://127.0.0.1") {
cachedURL = dbURL
}
@@ -439,12 +413,10 @@ func (h *RegistryHandler) Register(c *gin.Context) {
}
}
// Broadcast WORKSPACE_ONLINE — use the reconciled card so the canvas
// Agent Card view live-updates with the friendly name, matching what
// was just persisted (not the runtime's raw UUID-name card).
// Broadcast WORKSPACE_ONLINE
if err := h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.ID, map[string]interface{}{
"url": cachedURL,
"agent_card": reconciledCard,
"agent_card": payload.AgentCard,
"delivery_mode": effectiveMode,
}); err != nil {
log.Printf("Registry broadcast error: %v", err)
@@ -461,8 +433,8 @@ func (h *RegistryHandler) Register(c *gin.Context) {
// live token; they bootstrap one here on their next register call.
// New workspaces always pass through this path on their first boot.
response := gin.H{"status": "registered", "delivery_mode": effectiveMode}
if hasLive, hasLiveErr := wsauth.HasAnyLiveToken(ctx, db.DB, payload.ID); hasLiveErr == nil && !hasLive {
token, tokErr := wsauth.IssueToken(ctx, db.DB, payload.ID)
if hasLive, hasLiveErr := wsauth.HasAnyLiveToken(ctx, db.GetDB(), payload.ID); hasLiveErr == nil && !hasLive {
token, tokErr := wsauth.IssueToken(ctx, db.GetDB(), payload.ID)
if tokErr != nil {
// Don't fail the whole register on token-issuance error — the
// agent is already online per the upsert above. Log and continue.
@@ -530,7 +502,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
// Read previous current_task to detect changes (before the UPDATE)
var prevTask string
_ = db.DB.QueryRowContext(ctx, `SELECT COALESCE(current_task, '') FROM workspaces WHERE id = $1`, payload.WorkspaceID).Scan(&prevTask)
_ = db.GetDB().QueryRowContext(ctx, `SELECT COALESCE(current_task, '') FROM workspaces WHERE id = $1`, payload.WorkspaceID).Scan(&prevTask)
// #615: Clamp monthly_spend to a safe range before any DB write.
// A malicious or buggy agent could report math.MaxInt64, causing
@@ -556,7 +528,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
// zero to avoid accidentally clearing a previously-reported spend value.
var err error
if payload.MonthlySpend > 0 {
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
UPDATE workspaces SET
last_heartbeat_at = now(),
last_error_rate = $2,
@@ -571,7 +543,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
payload.ActiveTasks, payload.UptimeSeconds, payload.CurrentTask,
payload.MonthlySpend)
} else {
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
UPDATE workspaces SET
last_heartbeat_at = now(),
last_error_rate = $2,
@@ -683,7 +655,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
ctx := c.Request.Context()
var currentStatus string
err := db.DB.QueryRowContext(ctx, `SELECT status FROM workspaces WHERE id = $1`, payload.WorkspaceID).
err := db.GetDB().QueryRowContext(ctx, `SELECT status FROM workspaces WHERE id = $1`, payload.WorkspaceID).
Scan(&currentStatus)
if err != nil {
return
@@ -700,7 +672,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
// timeout — restart workspace"), which the canvas surfaces in the
// degraded card without the operator scraping container logs.
if payload.RuntimeState == "wedged" && currentStatus == "online" {
_, err := db.DB.ExecContext(ctx,
_, err := db.GetDB().ExecContext(ctx,
`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'online'`,
models.StatusDegraded, payload.WorkspaceID)
if err != nil {
@@ -724,7 +696,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
nativeStatus := runtimeOverrides.HasCapability(payload.WorkspaceID, "status_mgmt")
if !nativeStatus && currentStatus == "online" && payload.ErrorRate >= 0.5 {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusDegraded, payload.WorkspaceID); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusDegraded, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to mark %s degraded: %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
@@ -743,7 +715,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
// Skipped under native_status_mgmt for the same reason as the
// degrade branch above: the adapter owns the transition.
if !nativeStatus && currentStatus == "degraded" && payload.ErrorRate < 0.1 && payload.RuntimeState == "" {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusOnline, payload.WorkspaceID); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusOnline, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to recover %s to online: %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{})
@@ -753,7 +725,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
// #73 guard: `AND status = 'offline'` makes the flip conditional in a single statement,
// so a Delete that races with this recovery can't flip 'removed' back to 'online'.
if currentStatus == "offline" {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'offline'`, models.StatusOnline, payload.WorkspaceID); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'offline'`, models.StatusOnline, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to recover %s from offline: %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{})
@@ -766,7 +738,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
// transition is the only mechanism that moves newly-started workspaces out of
// the phantom-idle state. (#1784)
if currentStatus == "provisioning" {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'provisioning'`, models.StatusOnline, payload.WorkspaceID); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'provisioning'`, models.StatusOnline, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to transition %s from provisioning to online: %v", payload.WorkspaceID, err)
} else {
log.Printf("Heartbeat: transitioned %s from provisioning to online (heartbeat received)", payload.WorkspaceID)
@@ -794,7 +766,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
// heartbeats can't lift the workspace out of awaiting_agent on
// their own.
if currentStatus == "awaiting_agent" {
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'awaiting_agent'`, models.StatusOnline, payload.WorkspaceID); err != nil {
if _, err := db.GetDB().ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'awaiting_agent'`, models.StatusOnline, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to recover %s from awaiting_agent: %v", payload.WorkspaceID, err)
} else {
log.Printf("Heartbeat: transitioned %s from awaiting_agent to online (heartbeat received)", payload.WorkspaceID)
@@ -812,7 +784,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
// timeouts, retry logic, and activity_logs wiring.
if h.drainQueue != nil {
var maxConcurrent int
_ = db.DB.QueryRowContext(ctx,
_ = db.GetDB().QueryRowContext(ctx,
`SELECT COALESCE(max_concurrent_tasks, 1) FROM workspaces WHERE id = $1`,
payload.WorkspaceID,
).Scan(&maxConcurrent)
@@ -839,7 +811,7 @@ func (h *RegistryHandler) UpdateCard(c *gin.Context) {
}
agentCardStr := string(payload.AgentCard)
_, err := db.DB.ExecContext(c.Request.Context(), `
_, err := db.GetDB().ExecContext(c.Request.Context(), `
UPDATE workspaces SET agent_card = $2::jsonb, updated_at = now() WHERE id = $1
`, payload.WorkspaceID, agentCardStr)
if err != nil {
@@ -877,7 +849,7 @@ func (h *RegistryHandler) UpdateCard(c *gin.Context) {
func (h *RegistryHandler) requireWorkspaceToken(
ctx gincontext, c *gin.Context, workspaceID string,
) error {
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, workspaceID)
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.GetDB(), workspaceID)
if err != nil {
// DB error checking token existence — fail open so we don't take
// the whole heartbeat path down on a transient hiccup. Log loudly.
@@ -893,7 +865,7 @@ func (h *RegistryHandler) requireWorkspaceToken(
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
return errors.New("missing token")
}
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, token); err != nil {
if err := wsauth.ValidateToken(ctx, db.GetDB(), workspaceID, token); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
return err
}
@@ -120,7 +120,7 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
d := restartContextData{RestartAt: time.Now()}
var lastHB sql.NullTime
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT last_heartbeat_at FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&lastHB); err == nil && lastHB.Valid {
d.PrevSessionAt = lastHB.Time
@@ -132,7 +132,7 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
// the platform ever echoing secret material back into the
// message bus.
keySet := map[string]struct{}{}
if rows, err := db.DB.QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
if rows, err := db.GetDB().QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
@@ -141,7 +141,7 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
}
rows.Close()
}
if rows, err := db.DB.QueryContext(ctx,
if rows, err := db.GetDB().QueryContext(ctx,
`SELECT key FROM workspace_secrets WHERE workspace_id = $1`, workspaceID,
); err == nil {
for rows.Next() {
@@ -166,7 +166,7 @@ func waitForWorkspaceOnline(ctx context.Context, workspaceID string, timeout tim
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var status string
if err := db.DB.QueryRowContext(ctx,
if err := db.GetDB().QueryRowContext(ctx,
`SELECT status FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&status); err == nil && status == "online" {
return true
@@ -56,11 +56,9 @@ const (
// (an externally routable address) is used directly.
func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID string) {
// Non-blocking send — don't stall the restart cycle.
// Run in a tracked async goroutine (goAsync, not bare `go`) so the
// caller (runRestartCycle) can proceed to stopForRestart without
// waiting, while the test harness can still drain it before swapping
// the global db.DB (resolveAgentURLForRestartSignal reads db.DB).
h.goAsync(func() {
// Run in a detached goroutine so the caller (runRestartCycle) can
// proceed to stopForRestart without waiting.
go func() {
signalCtx, cancel := context.WithTimeout(context.Background(), restartSignalTimeout)
defer cancel()
@@ -111,7 +109,7 @@ func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID s
} else {
log.Printf("A2AGracefulRestart: %s returned status %d — proceeding with stop", workspaceID, resp.StatusCode)
}
})
}()
}
// resolveAgentURLForRestartSignal returns the routable URL for the workspace
@@ -127,7 +125,7 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
// Cache miss — fall back to DB.
var urlNullable *string
err = db.DB.QueryRowContext(ctx,
err = db.GetDB().QueryRowContext(ctx,
`SELECT url FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&urlNullable)
if err != nil {
@@ -97,7 +97,7 @@ func TestRewriteForDocker_LocalhostUrlRewritten(t *testing.T) {
// TestResolveAgentURLForRestartSignal_CacheHit verifies that a Redis-cached
// URL is returned without hitting the DB.
func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
_ = setupTestDB(t) // db.DB must be set before setupTestRedisWithURL
_ = setupTestDB(t) // db.GetDB() must be set before setupTestRedisWithURL
_ = setupTestRedisWithURL(t, "http://cached.internal:9000/agent")
h := newHandlerWithTestDeps(t)
@@ -118,7 +118,7 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
// TestResolveAgentURLForRestartSignal_DBError verifies that a DB error is
// returned and propagated when neither Redis cache nor DB lookup succeeds.
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
mock := setupTestDB(t) // must come before setupTestRedis so db.GetDB() is correct
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDeps(t)
@@ -140,7 +140,7 @@ func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
// TestResolveAgentURLForRestartSignal_CacheMiss verifies that on Redis miss,
// the URL is fetched from the DB and cached.
func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
mock := setupTestDB(t) // must come before setupTestRedis so db.GetDB() is correct
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDeps(t)
@@ -40,12 +40,12 @@ func resolveRuntimeImage(ctx context.Context, runtime string) string {
if os.Getenv("WORKSPACE_IMAGE_LOCAL_OVERRIDE") != "" {
return ""
}
if db.DB == nil {
if db.GetDB() == nil {
return ""
}
var digest string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT digest FROM runtime_image_pins WHERE template_name = $1`, runtime,
).Scan(&digest)
if err != nil {
@@ -44,7 +44,7 @@ func (h *ScheduleHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, workspace_id, name, cron_expr, timezone, prompt, enabled,
last_run_at, next_run_at, run_count, last_status, last_error,
source, created_at, updated_at
@@ -127,7 +127,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) {
// source='runtime' marks this row as user-created (Canvas/API). The
// org/import path inserts with source='template' and only refreshes
// template-source rows on re-import (issue #24), so runtime rows survive.
err = db.DB.QueryRowContext(ctx, `
err = db.GetDB().QueryRowContext(ctx, `
INSERT INTO workspace_schedules (workspace_id, name, cron_expr, timezone, prompt, enabled, next_run_at, source)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'runtime')
RETURNING id
@@ -176,7 +176,7 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
var nextRunAt *time.Time
if body.CronExpr != nil || body.Timezone != nil {
var currentCron, currentTZ string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`,
scheduleID, workspaceID,
).Scan(&currentCron, &currentTZ)
@@ -204,7 +204,7 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
nextRunAt = &nextRun
}
result, err := db.DB.ExecContext(ctx, `
result, err := db.GetDB().ExecContext(ctx, `
UPDATE workspace_schedules SET
name = COALESCE($2, name),
cron_expr = COALESCE($3, cron_expr),
@@ -235,7 +235,7 @@ func (h *ScheduleHandler) Delete(c *gin.Context) {
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
ctx := c.Request.Context()
result, err := db.DB.ExecContext(ctx,
result, err := db.GetDB().ExecContext(ctx,
`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`,
scheduleID, workspaceID)
if err != nil {
@@ -258,7 +258,7 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) {
ctx := c.Request.Context()
var prompt string
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT prompt FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`,
scheduleID, workspaceID,
).Scan(&prompt)
@@ -290,7 +290,7 @@ func (h *ScheduleHandler) History(c *gin.Context) {
// #152: include error_detail in history so UI can show why a run failed.
// activity_logs.error_detail is populated by scheduler.fireSchedule when
// the A2A proxy returns non-2xx or the update SQL reports an error.
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT created_at, duration_ms, status,
COALESCE(error_detail, '') as error_detail,
COALESCE(request_body::text, '{}') as request_body
@@ -390,7 +390,7 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
}
}
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id, name, enabled, last_run_at, next_run_at, run_count, last_status, last_error
FROM workspace_schedules
WHERE workspace_id = $1
+18 -18
View File
@@ -39,7 +39,7 @@ func (h *SecretsHandler) List(c *gin.Context) {
wsKeys := map[string]bool{}
secrets := make([]map[string]interface{}, 0)
rows, err := db.DB.QueryContext(ctx,
rows, err := db.GetDB().QueryContext(ctx,
`SELECT key, created_at, updated_at FROM workspace_secrets WHERE workspace_id = $1 ORDER BY key`,
workspaceID)
if err != nil {
@@ -68,7 +68,7 @@ func (h *SecretsHandler) List(c *gin.Context) {
}
// 2. Global secrets not overridden at workspace level
globalRows, err := db.DB.QueryContext(ctx,
globalRows, err := db.GetDB().QueryContext(ctx,
`SELECT key, created_at, updated_at FROM global_secrets ORDER BY key`)
if err != nil {
log.Printf("List global secrets (merged) error: %v", err)
@@ -127,7 +127,7 @@ func (h *SecretsHandler) Values(c *gin.Context) {
// Auth gate (Phase 30.1/30.2): enforce the bearer token when the
// workspace has any live token on file. Grandfather legacy workspaces
// through so a rolling upgrade doesn't lock them out.
hasLive, hlErr := wsauth.HasAnyLiveToken(ctx, db.DB, workspaceID)
hasLive, hlErr := wsauth.HasAnyLiveToken(ctx, db.GetDB(), workspaceID)
if hlErr != nil {
// DB hiccup checking token existence — the handler's security
// posture is "fail closed" here because unlike heartbeat, we're
@@ -143,7 +143,7 @@ func (h *SecretsHandler) Values(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
return
}
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, tok); err != nil {
if err := wsauth.ValidateToken(ctx, db.GetDB(), workspaceID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
return
}
@@ -157,7 +157,7 @@ func (h *SecretsHandler) Values(c *gin.Context) {
// instead of returning a partial bundle that boots a broken agent.
var failedKeys []string
globalRows, gErr := db.DB.QueryContext(ctx,
globalRows, gErr := db.GetDB().QueryContext(ctx,
`SELECT key, encrypted_value, encryption_version FROM global_secrets`)
if gErr == nil {
defer globalRows.Close()
@@ -185,7 +185,7 @@ func (h *SecretsHandler) Values(c *gin.Context) {
}
}
wsRows, wErr := db.DB.QueryContext(ctx,
wsRows, wErr := db.GetDB().QueryContext(ctx,
`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1`,
workspaceID)
if wErr == nil {
@@ -250,7 +250,7 @@ func (h *SecretsHandler) Set(c *gin.Context) {
// also rewrites the version — re-setting a secret while encryption
// is enabled upgrades a historical plaintext row to AES-GCM.
version := crypto.CurrentEncryptionVersion()
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value, encryption_version)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, key) DO UPDATE
@@ -280,7 +280,7 @@ func (h *SecretsHandler) Delete(c *gin.Context) {
key := c.Param("key")
ctx := c.Request.Context()
result, err := db.DB.ExecContext(ctx,
result, err := db.GetDB().ExecContext(ctx,
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = $2`,
workspaceID, key)
if err != nil {
@@ -313,7 +313,7 @@ func (h *SecretsHandler) Delete(c *gin.Context) {
// ListGlobal handles GET /admin/secrets
func (h *SecretsHandler) ListGlobal(c *gin.Context) {
ctx := c.Request.Context()
rows, err := db.DB.QueryContext(ctx,
rows, err := db.GetDB().QueryContext(ctx,
`SELECT key, created_at, updated_at FROM global_secrets ORDER BY key`)
if err != nil {
log.Printf("List global secrets error: %v", err)
@@ -362,7 +362,7 @@ func (h *SecretsHandler) SetGlobal(c *gin.Context) {
}
globalVersion := crypto.CurrentEncryptionVersion()
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO global_secrets (key, encrypted_value, encryption_version)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE
@@ -394,7 +394,7 @@ func (h *SecretsHandler) restartAllAffectedByGlobalKey(key string) {
return
}
ctx := context.Background()
rows, err := db.DB.QueryContext(ctx, `
rows, err := db.GetDB().QueryContext(ctx, `
SELECT id FROM workspaces
WHERE status NOT IN ('removed', 'paused')
AND COALESCE(runtime, '') <> 'external'
@@ -432,7 +432,7 @@ func (h *SecretsHandler) DeleteGlobal(c *gin.Context) {
key := c.Param("key")
ctx := c.Request.Context()
result, err := db.DB.ExecContext(ctx,
result, err := db.GetDB().ExecContext(ctx,
`DELETE FROM global_secrets WHERE key = $1`, key)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
@@ -464,7 +464,7 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
// Check if MODEL_PROVIDER secret exists
var modelBytes []byte
var modelVersion int
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
workspaceID).Scan(&modelBytes, &modelVersion)
if err == sql.ErrNoRows {
@@ -495,7 +495,7 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
// the gin handler re-adds that after a successful write.
func setModelSecret(ctx context.Context, workspaceID, model string) error {
if model == "" {
_, err := db.DB.ExecContext(ctx,
_, err := db.GetDB().ExecContext(ctx,
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
workspaceID)
return err
@@ -505,7 +505,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
return err
}
version := crypto.CurrentEncryptionVersion()
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value, encryption_version)
VALUES ($1, 'MODEL_PROVIDER', $2, $3)
ON CONFLICT (workspace_id, key) DO UPDATE
@@ -579,7 +579,7 @@ func (h *SecretsHandler) GetProvider(c *gin.Context) {
var bytesVal []byte
var version int
err := db.DB.QueryRowContext(ctx,
err := db.GetDB().QueryRowContext(ctx,
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'LLM_PROVIDER'`,
workspaceID).Scan(&bytesVal, &version)
if err == sql.ErrNoRows {
@@ -612,7 +612,7 @@ func (h *SecretsHandler) GetProvider(c *gin.Context) {
// the gin handler re-adds that after a successful write.
func setProviderSecret(ctx context.Context, workspaceID, provider string) error {
if provider == "" {
_, err := db.DB.ExecContext(ctx,
_, err := db.GetDB().ExecContext(ctx,
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'LLM_PROVIDER'`,
workspaceID)
return err
@@ -622,7 +622,7 @@ func setProviderSecret(ctx context.Context, workspaceID, provider string) error
return err
}
version := crypto.CurrentEncryptionVersion()
_, err = db.DB.ExecContext(ctx, `
_, err = db.GetDB().ExecContext(ctx, `
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value, encryption_version)
VALUES ($1, 'LLM_PROVIDER', $2, $3)
ON CONFLICT (workspace_id, key) DO UPDATE

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