Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b705270291 | |||
| 466f303015 | |||
| b6b14a38d2 | |||
| 7270b89a85 |
@@ -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
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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(¤tVersion)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤tStatus)
|
||||
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(¤tCron, ¤tTZ)
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user