Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a674a6547e | |||
| f2f5338183 | |||
| e01077be38 | |||
| f3187ea0c1 | |||
| f92ba492de | |||
| 00cfe51df7 | |||
| 55ef3176ed | |||
| 4b074f631b | |||
| 50c3bdfd6c | |||
| a33c879017 | |||
| e91186c4bf | |||
| 089be695a9 | |||
| dcc870a6b7 | |||
| d144dcc700 | |||
| 656a02fae4 | |||
| c53155ec5f | |||
| debe29c889 | |||
| 7a39a08837 | |||
| bb9bf85dbd | |||
| ff21bbb876 | |||
| da3cb4c098 | |||
| ef9bd1e0e2 | |||
| b759548822 | |||
| cce2050b6a | |||
| e87df906bd | |||
| c60e2b5fa2 | |||
| 143fbb91ff | |||
| 1b29b24e83 | |||
| 6033179f48 | |||
| ab1acff2d2 | |||
| 19df43e3da | |||
| dcece2762b | |||
| 57bfa40990 | |||
| d88fbb90fb | |||
| 2e6bed71b9 | |||
| 030377bb84 | |||
| f93957e982 | |||
| b530c147de | |||
| f39b595a9c | |||
| 95fdf86187 | |||
| 04f7a07add | |||
| 3dfeb180ab | |||
| 88ff0d770b | |||
| 86b8d8d744 | |||
| 9b9419ad5e | |||
| a19ee90556 | |||
| bd0580f4af | |||
| 64e58fb390 | |||
| 9ceda9d81f | |||
| b6310d7ebf | |||
| 0886dbc923 | |||
| 38bc27df0d | |||
| 4a2dda7cac |
@@ -1,7 +1,7 @@
|
||||
name: Block internal-flavored paths
|
||||
|
||||
# Hard CI gate. Internal content (positioning, competitive briefs, sales
|
||||
# playbooks, PMM/press drip, draft campaigns) lives in Molecule-AI/internal —
|
||||
# playbooks, PMM/press drip, draft campaigns) lives in molecule-ai/internal —
|
||||
# this public monorepo must never re-acquire those paths. CEO directive
|
||||
# 2026-04-23 after a fleet-wide audit found 79 internal files leaked here.
|
||||
#
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
echo "::error::Forbidden internal-flavored paths detected:"
|
||||
printf "$OFFENDING"
|
||||
echo ""
|
||||
echo "These paths belong in Molecule-AI/internal, not this public repo."
|
||||
echo "These paths belong in molecule-ai/internal, not this public repo."
|
||||
echo "See docs/internal-content-policy.md for canonical locations."
|
||||
echo ""
|
||||
echo "If your file is genuinely public-facing (e.g. a blog post"
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
echo
|
||||
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
|
||||
echo "Phase 2 canary fleet has not been stood up yet —"
|
||||
echo "see [canary-tenants.md](https://github.com/Molecule-AI/molecule-controlplane/blob/main/docs/canary-tenants.md)."
|
||||
echo "see [canary-tenants.md](https://github.com/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
|
||||
echo
|
||||
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run: go mod download
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli
|
||||
# CLI (molecli) moved to standalone repo: github.com/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go vet ./... || true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
# Strip the package-import prefix so we can match .coverage-allowlist.txt
|
||||
# entries written as paths relative to workspace-server/.
|
||||
# Handle both module paths: platform/workspace-server/... and platform/...
|
||||
rel=$(echo "$file" | sed 's|^github.com/Molecule-AI/molecule-monorepo/platform/workspace-server/||; s|^github.com/Molecule-AI/molecule-monorepo/platform/||')
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
|
||||
@@ -243,8 +243,8 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
# MCP Server + SDK removed from CI — now in standalone repos:
|
||||
# - github.com/Molecule-AI/molecule-mcp-server (npm CI)
|
||||
# - github.com/Molecule-AI/molecule-sdk-python (PyPI CI)
|
||||
# - github.com/molecule-ai/molecule-mcp-server (npm CI)
|
||||
# - github.com/molecule-ai/molecule-sdk-python (PyPI CI)
|
||||
|
||||
# e2e-api job moved to .github/workflows/e2e-api.yml (issue #458).
|
||||
# It now has workflow-level concurrency (cancel-in-progress: false) so
|
||||
@@ -434,5 +434,5 @@ jobs:
|
||||
fi
|
||||
|
||||
# SDK + plugin validation moved to standalone repo:
|
||||
# github.com/Molecule-AI/molecule-sdk-python
|
||||
# github.com/molecule-ai/molecule-sdk-python
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
repository: molecule-ai/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -121,8 +121,16 @@ jobs:
|
||||
# Per-migration result is logged so a failed migration that
|
||||
# SHOULD have been replayable surfaces in the CI log instead
|
||||
# of silently failing.
|
||||
# Apply both *.sql (legacy, lives next to its module) and
|
||||
# *.up.sql (newer up/down convention) in a single
|
||||
# lexicographically-sorted pass. Excluding *.down.sql so the
|
||||
# newest-naming-convention pairs don't undo themselves mid-run.
|
||||
# Pre-#149-followup this loop only globbed *.up.sql, which
|
||||
# silently skipped 001_workspaces.sql + 009_activity_logs.sql
|
||||
# — fine while no integration test depended on those tables,
|
||||
# not fine once a cross-table atomicity test came in.
|
||||
set +e
|
||||
for migration in migrations/*.up.sql; do
|
||||
for migration in $(ls migrations/*.sql 2>/dev/null | grep -v '\.down\.sql$' | sort); do
|
||||
if psql -h localhost -U postgres -d molecule -v ON_ERROR_STOP=1 \
|
||||
-f "$migration" >/dev/null 2>&1; then
|
||||
echo "✓ $(basename "$migration")"
|
||||
@@ -132,16 +140,19 @@ jobs:
|
||||
done
|
||||
set -e
|
||||
|
||||
# Sanity: the delegations table MUST exist for the integration
|
||||
# tests to be meaningful. Hard-fail if 049 didn't land — that
|
||||
# would be a real regression we want loud.
|
||||
if ! psql -h localhost -U postgres -d molecule -tA \
|
||||
-c "SELECT 1 FROM information_schema.tables WHERE table_name = 'delegations'" \
|
||||
| grep -q 1; then
|
||||
echo "::error::delegations table missing after migration replay — handler integration tests would be meaningless"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ delegations table present"
|
||||
# Sanity: the delegations + workspaces + activity_logs tables
|
||||
# MUST exist for the integration tests to be meaningful. Hard-
|
||||
# fail if any didn't land — that would be a real regression we
|
||||
# want loud.
|
||||
for tbl in delegations workspaces activity_logs pending_uploads; do
|
||||
if ! psql -h localhost -U postgres -d molecule -tA \
|
||||
-c "SELECT 1 FROM information_schema.tables WHERE table_name = '$tbl'" \
|
||||
| grep -q 1; then
|
||||
echo "::error::$tbl table missing after migration replay — handler integration tests would be meaningless"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $tbl table present"
|
||||
done
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true'
|
||||
name: Run integration tests
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
repository: molecule-ai/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@ permissions:
|
||||
|
||||
jobs:
|
||||
disable-auto-merge-on-push:
|
||||
uses: Molecule-AI/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
|
||||
uses: molecule-ai/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
|
||||
|
||||
@@ -25,7 +25,7 @@ name: publish-runtime
|
||||
# 3. Publishes to PyPI via the PyPA Trusted Publisher action (OIDC).
|
||||
# No static API token is stored — PyPI verifies the workflow's
|
||||
# OIDC claim against the trusted-publisher config registered for
|
||||
# molecule-ai-workspace-runtime (Molecule-AI/molecule-core,
|
||||
# molecule-ai-workspace-runtime (molecule-ai/molecule-core,
|
||||
# publish-runtime.yml, environment pypi-publish).
|
||||
#
|
||||
# After publish: the 8 template repos pick up the new version on their
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Publish to PyPI (Trusted Publisher / OIDC)
|
||||
# PyPI side is configured: project molecule-ai-workspace-runtime →
|
||||
# publisher Molecule-AI/molecule-core, workflow publish-runtime.yml,
|
||||
# publisher molecule-ai/molecule-core, workflow publish-runtime.yml,
|
||||
# environment pypi-publish. The action mints a short-lived OIDC
|
||||
# token and exchanges it for a PyPI upload credential — no static
|
||||
# API token in this repo's secrets.
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
|
||||
FAILED=""
|
||||
for tpl in $TEMPLATES; do
|
||||
REPO="Molecule-AI/molecule-ai-workspace-template-$tpl"
|
||||
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
|
||||
STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \
|
||||
-X POST "https://api.github.com/repos/$REPO/dispatches" \
|
||||
-H "Authorization: Bearer $DISPATCH_TOKEN" \
|
||||
|
||||
@@ -80,12 +80,12 @@ jobs:
|
||||
#
|
||||
# Uses a fine-grained PAT (PLUGIN_REPO_PAT) because the plugin repo
|
||||
# is private and the default GITHUB_TOKEN is scoped to THIS repo.
|
||||
# The PAT needs Contents:Read on Molecule-AI/molecule-ai-plugin-
|
||||
# The PAT needs Contents:Read on molecule-ai/molecule-ai-plugin-
|
||||
# github-app-auth. Falls back to the default token for the (rare)
|
||||
# case where an operator made the plugin repo public.
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
repository: molecule-ai/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ name: redeploy-tenants-on-main
|
||||
#
|
||||
# This workflow closes the gap by calling the control-plane admin
|
||||
# endpoint that performs a canary-first, batched, health-gated rolling
|
||||
# redeploy across every live tenant. Implemented in Molecule-AI/
|
||||
# redeploy across every live tenant. Implemented in molecule-ai/
|
||||
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
|
||||
# (feat/tenant-auto-redeploy, landing alongside this workflow).
|
||||
#
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
|
||||
# Molecule-AI/molecule-core, matching the staging/prod CP's
|
||||
# molecule-ai/molecule-core, matching the staging/prod CP's
|
||||
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
|
||||
# repo's secrets for CI.
|
||||
env:
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Call staging-CP redeploy-fleet
|
||||
# CP_STAGING_ADMIN_API_TOKEN must be set as a repo/org secret
|
||||
# on Molecule-AI/molecule-core, matching staging-CP's
|
||||
# on molecule-ai/molecule-core, matching staging-CP's
|
||||
# CP_ADMIN_API_TOKEN env var (visible in Railway controlplane
|
||||
# / staging environment). Stored separately from the prod
|
||||
# CP_ADMIN_API_TOKEN so a leak of one doesn't auth the other.
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
--body "$(cat <<'BODY'
|
||||
[retarget-bot] This PR was opened against `main` and has been retargeted to `staging` automatically.
|
||||
|
||||
**Why:** per [SHARED_RULES rule 8](https://github.com/Molecule-AI/molecule-ai-org-template-molecule-dev/blob/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.
|
||||
**Why:** per [SHARED_RULES rule 8](https://github.com/molecule-ai/molecule-ai-org-template-molecule-dev/blob/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.
|
||||
|
||||
**What changed:** just the base branch — no code change. CI will re-run against `staging`. If you get merge conflicts, rebase on `staging`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ name: Secret scan
|
||||
#
|
||||
# jobs:
|
||||
# secret-scan:
|
||||
# uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||
# uses: molecule-ai/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||
#
|
||||
# Pin to @staging not @main — staging is the active default branch,
|
||||
# main lags via the staging-promotion workflow. Updates ride along
|
||||
|
||||
@@ -108,6 +108,14 @@ jobs:
|
||||
python3 > stale_slugs.txt <<'PY'
|
||||
import json, os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
# SSOT for this list lives in the controlplane Go code:
|
||||
# molecule-controlplane/internal/slugs/ephemeral.go
|
||||
# (var EphemeralPrefixes). The redeploy-fleet auto-rollout
|
||||
# also reads from there to SKIP these slugs — without that
|
||||
# filter, fleet redeploy SSM-failed in-flight E2E tenants
|
||||
# whose containers were still booting, breaking the test
|
||||
# that just spun them up (molecule-controlplane#493).
|
||||
# Update both files together.
|
||||
EPHEMERAL_PREFIXES = ("e2e-", "rt-e2e-")
|
||||
with open("orgs.json") as f:
|
||||
data = json.load(f)
|
||||
@@ -185,7 +193,47 @@ jobs:
|
||||
# sweeper is best-effort. Next hourly tick re-attempts. We
|
||||
# only fail loud at the safety-cap gate above.
|
||||
|
||||
- name: Sweep orphan tunnels
|
||||
# Stale-org cleanup deletes the org (which cascades to tunnel
|
||||
# delete inside the CP). But when that cascade fails partway —
|
||||
# CP transient 5xx after the org row is deleted but before the
|
||||
# CF tunnel delete completes — the tunnel persists with no
|
||||
# matching org row. The reconciler in internal/sweep flags this
|
||||
# as `cf_tunnel kind=orphan`, but nothing automatically reaps it.
|
||||
#
|
||||
# `/cp/admin/orphan-tunnels/cleanup` is the operator-triggered
|
||||
# reaper. Calling it here at the end of every sweep tick
|
||||
# converges the staging CF account to clean even when CP
|
||||
# cascades half-fail.
|
||||
#
|
||||
# PR #492 made the underlying DeleteTunnel actually check
|
||||
# status — pre-fix it silent-succeeded on CF code 1022
|
||||
# ("active connections"), so this step would have been a no-op
|
||||
# against stuck connectors. Post-fix the cleanup invokes
|
||||
# CleanupTunnelConnections + retry, which actually clears the
|
||||
# 1022 case. (#2987)
|
||||
#
|
||||
# Best-effort. Failure here doesn't fail the workflow — next
|
||||
# tick re-attempts. Errors flow to step output for ops review.
|
||||
if: env.DRY_RUN != 'true'
|
||||
run: |
|
||||
set +e
|
||||
curl -sS -o /tmp/cleanup_resp -w "%{http_code}" \
|
||||
--max-time 60 \
|
||||
-X POST "$MOLECULE_CP_URL/cp/admin/orphan-tunnels/cleanup" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" >/tmp/cleanup_code
|
||||
set -e
|
||||
http_code=$(cat /tmp/cleanup_code 2>/dev/null || echo "000")
|
||||
body=$(cat /tmp/cleanup_resp 2>/dev/null | head -c 500)
|
||||
if [ "$http_code" = "200" ]; then
|
||||
count=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('deleted_count', 0))" 2>/dev/null || echo "0")
|
||||
failed_n=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(len(d.get('failed') or {}))" 2>/dev/null || echo "0")
|
||||
echo "Orphan-tunnel sweep: deleted=$count failed=$failed_n"
|
||||
else
|
||||
echo "::warning::orphan-tunnels cleanup returned HTTP $http_code — body: $body"
|
||||
fi
|
||||
|
||||
- name: Dry-run summary
|
||||
if: env.DRY_RUN == 'true'
|
||||
run: |
|
||||
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s). Re-run with dry_run=false to actually delete."
|
||||
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s) AND triggered orphan-tunnels cleanup. Re-run with dry_run=false to actually delete."
|
||||
|
||||
@@ -325,7 +325,6 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{dropdownOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{opt.kind ? ` (${opt.kind})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -287,7 +287,7 @@ export function SidePanel() {
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "memory" && <MemoryInspectorPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
|
||||
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
||||
import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews";
|
||||
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";
|
||||
@@ -1137,8 +1138,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className={`flex flex-wrap gap-1 ${msg.content ? "mt-1.5" : ""}`}>
|
||||
{msg.attachments.map((att, i) => (
|
||||
<AttachmentChip
|
||||
<AttachmentPreview
|
||||
key={`${msg.id}-${i}`}
|
||||
workspaceId={workspaceId}
|
||||
attachment={att}
|
||||
onDownload={downloadAttachment}
|
||||
tone={msg.role === "user" ? "user" : "agent"}
|
||||
|
||||
@@ -262,6 +262,27 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
setOriginalProvider("");
|
||||
}
|
||||
|
||||
// Skip the config.yaml fetch entirely for runtimes that manage
|
||||
// their own config (external, hermes, etc.) — they don't have a
|
||||
// platform-side template, so the GET would 404. The catch block
|
||||
// below handles 404 gracefully, but issuing the request adds
|
||||
// browser-console noise + a wasted RTT on every open of the
|
||||
// Config tab for the affected workspaces. Reported on
|
||||
// production reno-stars 2026-05-05 (workspace runtime=external,
|
||||
// 404 on /files/config.yaml visible in the console even though
|
||||
// the form rendered correctly).
|
||||
if (RUNTIMES_WITH_OWN_CONFIG.has(wsMetadataRuntime)) {
|
||||
setConfig({
|
||||
...DEFAULT_CONFIG,
|
||||
runtime: wsMetadataRuntime,
|
||||
model: wsMetadataModel,
|
||||
...(wsMetadataModel ? { runtime_config: { model: wsMetadataModel } } : {}),
|
||||
...(wsMetadataTier !== null ? { tier: wsMetadataTier } : {}),
|
||||
} as ConfigData);
|
||||
setOriginalModel(wsMetadataModel);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/config.yaml`);
|
||||
const parsed = parseYaml(res.content);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { showToast } from "../Toaster";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
import { FilesToolbar } from "./FilesTab/FilesToolbar";
|
||||
import { FileTree } from "./FilesTab/FileTree";
|
||||
import { FileEditor } from "./FilesTab/FileEditor";
|
||||
import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
|
||||
import { useFilesApi } from "./FilesTab/useFilesApi";
|
||||
import { buildTree } from "./FilesTab/tree";
|
||||
|
||||
@@ -14,9 +16,40 @@ export type { TreeNode } from "./FilesTab/tree";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
/** Workspace metadata from the canvas store. Optional for back-compat
|
||||
* with any caller that still mounts <FilesTab workspaceId=.../> without
|
||||
* threading data through (legacy tests). When present, runtime gates
|
||||
* the early-return below. Mirrors TerminalTab's prop shape (#2830). */
|
||||
data?: WorkspaceNodeData;
|
||||
}
|
||||
|
||||
export function FilesTab({ workspaceId }: Props) {
|
||||
/** Runtimes whose filesystem the platform doesn't own. The canvas can't
|
||||
* list/read/write files on these — the agent runs on the user's own
|
||||
* hardware (mac laptop, mac mini, hermes-on-home-server) and reaches
|
||||
* the platform via the heartbeat-based polling Phase 30 layer.
|
||||
*
|
||||
* Keep narrow — only add a runtime here when its provisioner genuinely
|
||||
* has no platform-owned filesystem. Otherwise the user loses access to
|
||||
* a real surface (e.g. claude-code SaaS workspaces have files served
|
||||
* by ListFiles via EIC; they belong on the rendering path, not here). */
|
||||
const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
|
||||
|
||||
export function FilesTab({ workspaceId, data }: Props) {
|
||||
// Early-return for runtimes whose filesystem is not platform-owned.
|
||||
// Skips the whole useFilesApi hook + tree render below — without this,
|
||||
// mounting the tab for an external workspace would issue a GET that
|
||||
// the platform can technically answer (it reads its own DB row, not
|
||||
// the user's machine), but every result row is fictional. Showing
|
||||
// "0 files / No config files yet" reads as a bug. The placeholder
|
||||
// makes the absence intentional and points the user at the right
|
||||
// surface (Chat).
|
||||
if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const [root, setRoot] = useState("/configs");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
@@ -45,11 +78,36 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
downloadFileByPath,
|
||||
downloadAllFiles,
|
||||
uploadFiles,
|
||||
uploadDataTransferItems,
|
||||
deleteAllFiles,
|
||||
} = useFilesApi(workspaceId, root);
|
||||
|
||||
// PR-D: track whether the user is currently dragging files OVER
|
||||
// the root area (not over a specific subdir row). Used to show
|
||||
// the "Drop to upload to root" highlight on the tree column.
|
||||
const [rootDragHover, setRootDragHover] = useState(false);
|
||||
|
||||
const handleDropToTarget = (
|
||||
targetDir: string,
|
||||
items: DataTransferItemList,
|
||||
) => {
|
||||
// canDelete is the gate proxy — same constraint as the toolbar
|
||||
// Upload button (today only /configs is writable from the canvas
|
||||
// surface). Without this check, dropping on /home would post
|
||||
// through /workspaces/<id>/files/<path>, which the backend would
|
||||
// reject only after an HTTP round-trip. Fail fast.
|
||||
if (root !== "/configs") {
|
||||
setError(
|
||||
`Upload only allowed in /configs (current root: ${root}). Switch root or use Upload button.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void uploadDataTransferItems(items, targetDir);
|
||||
};
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
@@ -190,8 +248,46 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* File tree */}
|
||||
<div className="w-[180px] border-r border-line/40 overflow-y-auto shrink-0">
|
||||
{/* File tree column. PR-D: outer div is the drop zone for
|
||||
"drop on root" — when the user drags into the column area
|
||||
(not over a specific subdir row), the drop targets the
|
||||
current root directory. Subdirectory rows in <FileTree>
|
||||
stop propagation on their own drop event so a drop on
|
||||
/configs/skills doesn't ALSO fire root-area drop. */}
|
||||
<div
|
||||
className={`w-[180px] border-r border-line/40 overflow-y-auto shrink-0 transition-colors ${
|
||||
rootDragHover ? "bg-accent/10 outline outline-1 outline-accent/40 -outline-offset-2" : ""
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
// Only highlight + accept the drop when uploads are
|
||||
// actually allowed for the current root. Without this
|
||||
// check the user gets a misleading drag affordance,
|
||||
// drops, then sees the toolbar's "switch root" toast —
|
||||
// bad UX.
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
setRootDragHover(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
|
||||
setRootDragHover(false);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
setRootDragHover(false);
|
||||
if (e.dataTransfer.items?.length) {
|
||||
handleDropToTarget("", e.dataTransfer.items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* New file input */}
|
||||
{showNewFile && (
|
||||
<div className="px-2 py-1 border-b border-line/40">
|
||||
@@ -209,14 +305,27 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
|
||||
No config files yet
|
||||
{rootDragHover
|
||||
? "Drop to upload to root"
|
||||
: root === "/configs"
|
||||
? "No config files yet — drag files here to upload"
|
||||
: "No config files yet"}
|
||||
</div>
|
||||
) : (
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
selectedPath={selectedFile}
|
||||
onSelect={openFile}
|
||||
// Delete is currently gated to /configs to match the
|
||||
// toolbar's New / Upload / Clear affordances. Context
|
||||
// menu and inline ✕ both honour the gate. PR-A made the
|
||||
// backend EIC delete work on all roots — keeping the
|
||||
// canvas gate conservative until we want to expose
|
||||
// /home /workspace deletion intentionally.
|
||||
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
|
||||
onDownload={downloadFileByPath}
|
||||
canDelete={root === "/configs"}
|
||||
onDropToTarget={handleDropToTarget}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
loadingDir={loadingDir}
|
||||
|
||||
@@ -1,41 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type TreeNode, getIcon } from "./tree";
|
||||
import { FileTreeContextMenu, type MenuItem } from "./FileTreeContextMenu";
|
||||
|
||||
interface TreeCallbacks {
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
/** PR-C: right-click → Download. Files only — directories ignore. */
|
||||
onDownload: (path: string) => void;
|
||||
/** Whether the active root permits delete. Wire into the Delete
|
||||
* context-menu item's `disabled` flag so the user gets the same
|
||||
* affordance as the toolbar (which gates Clear/New on /configs). */
|
||||
canDelete: boolean;
|
||||
/** PR-D: drop files/folders from the OS onto this row. targetDir
|
||||
* is the directory path (relative to the active root) under which
|
||||
* the dropped contents should land; "" means root. */
|
||||
onDropToTarget?: (targetDir: string, items: DataTransferItemList) => void;
|
||||
expandedDirs: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
loadingDir: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileTree renders the workspace tree + owns the right-click context
|
||||
* menu (PR-C) and the drop-target hover state (PR-D). Lifting the
|
||||
* menu state here (vs each row) means only one menu open at a time —
|
||||
* opening a new row's menu auto-closes the prior one. Same UX as
|
||||
* VSCode / Theia.
|
||||
*/
|
||||
export function FileTree({
|
||||
nodes,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth = 0,
|
||||
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
|
||||
const [menu, setMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
} | null>(null);
|
||||
// PR-D: hover-target highlight state for drag-drop. Lifted next to
|
||||
// the menu state so both shared-across-rows interactions live in
|
||||
// one place.
|
||||
const [hoverDir, setHoverDir] = useState<string | null>(null);
|
||||
|
||||
const openContextMenu = (e: React.MouseEvent, node: TreeNode) => {
|
||||
e.preventDefault();
|
||||
// Items composed per-row so the available actions reflect the
|
||||
// node type (files get Open + Download; directories get Delete
|
||||
// only since "open a directory in the editor" doesn't apply
|
||||
// and "Export folder" is the toolbar's job).
|
||||
const items: MenuItem[] = [];
|
||||
if (!node.isDir) {
|
||||
items.push({
|
||||
id: "open",
|
||||
label: "Open",
|
||||
icon: "⤴",
|
||||
onClick: () => onSelect(node.path),
|
||||
});
|
||||
items.push({
|
||||
id: "download",
|
||||
label: "Download",
|
||||
icon: "↓",
|
||||
onClick: () => onDownload(node.path),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: "✕",
|
||||
destructive: true,
|
||||
disabled: !canDelete,
|
||||
onClick: () => onDelete(node.path),
|
||||
});
|
||||
setMenu({ x: e.clientX, y: e.clientY, items });
|
||||
};
|
||||
|
||||
// Single state lifted to the top-level tree; nested <FileTree>s
|
||||
// (rendered for expanded directories below) do NOT instantiate
|
||||
// their own menus or drop-targets — they call back via prop
|
||||
// drilling. This keeps "only one menu open" + "only one drop
|
||||
// target highlighted" as structural invariants rather than
|
||||
// render-order coincidences.
|
||||
const childCallbacks: TreeCallbacks = {
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => (
|
||||
<TreeItem
|
||||
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
openContextMenu={openContextMenu}
|
||||
hoverDir={hoverDir}
|
||||
setHoverDir={setHoverDir}
|
||||
depth={depth}
|
||||
{...childCallbacks}
|
||||
/>
|
||||
))}
|
||||
{menu && (
|
||||
<FileTreeContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={menu.items}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,22 +133,81 @@ function TreeItem({
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth,
|
||||
}: TreeCallbacks & { node: TreeNode; depth: number }) {
|
||||
openContextMenu,
|
||||
hoverDir,
|
||||
setHoverDir,
|
||||
}: TreeCallbacks & {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
openContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
|
||||
hoverDir: string | null;
|
||||
setHoverDir: (p: string | null) => void;
|
||||
}) {
|
||||
const isSelected = selectedPath === node.path;
|
||||
const expanded = expandedDirs.has(node.path);
|
||||
const isLoading = loadingDir === node.path;
|
||||
const isDropTarget = node.isDir && hoverDir === node.path;
|
||||
|
||||
// PR-D drag handlers — only directory rows are valid drop targets
|
||||
// (dropping a file ON another file is ambiguous; treat it as
|
||||
// dropping in the parent dir, which the root area handles). When a
|
||||
// drag enters a directory row, mark it the hover target. When the
|
||||
// cursor leaves to a non-child element, clear it. drop fires the
|
||||
// upload callback with the row's path.
|
||||
const dragProps = node.isDir && onDropToTarget
|
||||
? {
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
// preventDefault is REQUIRED to opt this element into the
|
||||
// drop target list — without it, browsers refuse to fire
|
||||
// the drop event regardless of the drop handler.
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
onDragEnter: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setHoverDir(node.path);
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent) => {
|
||||
// Only clear hover when leaving to an element OUTSIDE this
|
||||
// row — bare leave-events fire for every child crossed
|
||||
// (the icon, the label, the ✕ button). Without the
|
||||
// contains() check the highlight flickers.
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
|
||||
setHoverDir(null);
|
||||
}
|
||||
},
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHoverDir(null);
|
||||
if (e.dataTransfer.items?.length) {
|
||||
onDropToTarget(node.path, e.dataTransfer.items);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
|
||||
className={`group w-full flex items-center gap-1 px-2 py-0.5 text-left transition-colors cursor-pointer ${
|
||||
isDropTarget
|
||||
? "bg-accent/20 outline outline-1 outline-accent/60"
|
||||
: "hover:bg-surface-card/40"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
{...dragProps}
|
||||
>
|
||||
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||
<span className="text-[10px]">📁</span>
|
||||
@@ -82,6 +229,9 @@ function TreeItem({
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={canDelete}
|
||||
onDropToTarget={onDropToTarget}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
@@ -99,6 +249,7 @@ function TreeItem({
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
>
|
||||
<span className="text-[9px]">{getIcon(node.name, false)}</span>
|
||||
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* FileTreeContextMenu — VSCode-style right-click menu for a single
|
||||
* file-tree row. Pops at the cursor's viewport coords; dismisses on
|
||||
* outside-click, Esc, blur, or scroll.
|
||||
*
|
||||
* Why a custom component (no library): the menu is one of several
|
||||
* "small popovers" in canvas; pulling in a dnd / popover lib for one
|
||||
* surface adds 10x the bytes of this implementation. The patterns
|
||||
* (outside-click + Esc + portal-free fixed position) match the
|
||||
* ContextMenu used in canvas/Toolbar so the keyboard-nav muscle
|
||||
* memory is uniform.
|
||||
*
|
||||
* Items are rendered from a `MenuItem[]` so callers can add/remove
|
||||
* actions without touching this component (e.g. PR-D will add an
|
||||
* "Upload to this folder" item for directory rows).
|
||||
*
|
||||
* Accessibility:
|
||||
* - role="menu" + role="menuitem" so screen readers announce the
|
||||
* surface as a menu, not a generic div.
|
||||
* - First item gets autofocus so keyboard users can ↓/↑/Enter without
|
||||
* reaching for the mouse.
|
||||
* - Esc + outside-click + Tab dismisses; behaves like every other
|
||||
* menu the user has touched on the canvas.
|
||||
*/
|
||||
export interface MenuItem {
|
||||
/** Stable identifier for testing + analytics. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional left icon glyph; not load-bearing. */
|
||||
icon?: string;
|
||||
/** Destructive (rendered in red) — for Delete-class actions. */
|
||||
destructive?: boolean;
|
||||
/** Item-specific click handler. The menu auto-closes after onClick
|
||||
* fires so handlers don't have to call onClose themselves. */
|
||||
onClick: () => void;
|
||||
/** Disabled items render but don't fire onClick (useful for
|
||||
* Delete-on-non-/configs case where the caller wants to surface
|
||||
* the item but explain it's gated). Currently unused — placeholder
|
||||
* for future options. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Viewport-coordinate position of the cursor that opened the menu. */
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// First item gets initial focus for keyboard ↓/↑/Enter nav.
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
firstItemRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Outside-click + Esc dismiss. Per memory
|
||||
// (feedback_abort_controller_for_rerendered_listeners), use an
|
||||
// AbortController so re-mounts (caller toggles the menu) don't leak
|
||||
// listeners.
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
// Roving focus across .menuitem buttons. Doing this with
|
||||
// tabindex management because Tab / Shift+Tab leave the menu
|
||||
// (which is the right thing — the user is escaping the menu).
|
||||
e.preventDefault();
|
||||
const buttons = ref.current?.querySelectorAll<HTMLButtonElement>(
|
||||
"[role='menuitem']:not([disabled])",
|
||||
);
|
||||
if (!buttons || buttons.length === 0) return;
|
||||
const arr = Array.from(buttons);
|
||||
const cur = arr.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur + 1) % arr.length
|
||||
: (cur - 1 + arr.length) % arr.length;
|
||||
arr[next].focus();
|
||||
}
|
||||
};
|
||||
// `mousedown` (not `click`) so the menu dismisses BEFORE the
|
||||
// tree-row's click handler would fire — otherwise clicking
|
||||
// outside also selects a different row, which is not what the
|
||||
// user expected when "outside-click closes the menu".
|
||||
document.addEventListener("mousedown", onPointerDown, { signal: ctrl.signal });
|
||||
document.addEventListener("keydown", onKeyDown, { signal: ctrl.signal });
|
||||
// Scroll inside any ancestor also dismisses — the fixed-position
|
||||
// menu would otherwise stay anchored to viewport coords while the
|
||||
// row it points at scrolled away. Use capture so we catch scroll
|
||||
// on inner panels (FileTree's overflow-y-auto wrapper).
|
||||
document.addEventListener("scroll", onClose, { signal: ctrl.signal, capture: true });
|
||||
return () => ctrl.abort();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
aria-label="File actions"
|
||||
className="fixed z-[1000] min-w-[140px] py-1 bg-surface-elevated border border-line/60 rounded-md shadow-xl shadow-black/30 text-[11px]"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={i === 0 ? firstItemRef : undefined}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
className={
|
||||
item.destructive
|
||||
? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus:bg-red-900/30 focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
}
|
||||
>
|
||||
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NotAvailablePanel — full-tab placeholder for runtimes whose filesystem
|
||||
* the platform doesn't own (today: runtime === "external").
|
||||
*
|
||||
* Pre-fix the FilesTab tried to GET /workspaces/<id>/files for these
|
||||
* workspaces. The platform answered with [] (no rows in workspace_files
|
||||
* for an external workspace by definition), but the canvas rendered
|
||||
* "0 files / No config files yet" which reads identically to the SaaS
|
||||
* empty-listing bug fixed in PR-A. Showing an explicit placeholder
|
||||
* makes the absence intentional and routes the user toward the
|
||||
* supported surface (Chat) for these workspaces.
|
||||
*
|
||||
* Mirrors the same affordance TerminalTab adopted for runtimes without
|
||||
* a TTY in PR #2830 — uniform "feature-not-applicable" UX across tabs.
|
||||
*/
|
||||
export function NotAvailablePanel({ runtime }: { runtime: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-surface-sunken/30">
|
||||
{/* Folder-with-slash icon. Custom inline SVG so we don't depend
|
||||
on an icon set being present at canvas build-time (matches
|
||||
TerminalTab's NotAvailablePanel pattern). */}
|
||||
<svg
|
||||
width="72"
|
||||
height="72"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-ink-soft mb-4"
|
||||
>
|
||||
{/* Folder body */}
|
||||
<path
|
||||
d="M10 22 L10 56 a4 4 0 0 0 4 4 L58 60 a4 4 0 0 0 4 -4 L62 26 a4 4 0 0 0 -4 -4 L34 22 L28 16 L14 16 a4 4 0 0 0 -4 4 Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/* Diagonal cancel slash */}
|
||||
<path
|
||||
d="M14 14 L58 58"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
|
||||
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
|
||||
This workspace runs the{" "}
|
||||
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
||||
whose filesystem isn't owned by the platform. Use the Chat tab to
|
||||
interact with the agent directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the right-click context menu added in PR-C of issue #2999.
|
||||
// VSCode-style affordance: Open / Download / Delete on file rows,
|
||||
// Delete on directory rows. Delete is gated by `canDelete` (parent
|
||||
// only enables on /configs root, matching the toolbar's gate).
|
||||
//
|
||||
// Pinned branches:
|
||||
// 1. Right-click on a file row opens the menu at the click coords
|
||||
// with Open + Download + Delete items.
|
||||
// 2. Right-click on a directory row opens the menu with Delete
|
||||
// only (no Open/Download — directories don't have one-click
|
||||
// semantics in this surface).
|
||||
// 3. Clicking Download fires the onDownload callback with the
|
||||
// row's path.
|
||||
// 4. Clicking Delete fires onDelete with the row's path (when
|
||||
// canDelete=true).
|
||||
// 5. Delete is disabled in the rendered menu when canDelete=false
|
||||
// and clicking it does NOT fire onDelete (gate is real).
|
||||
// 6. Esc dismisses the menu.
|
||||
// 7. Click outside the menu dismisses it.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
|
||||
const dir: TreeNode = {
|
||||
name: "skills",
|
||||
path: "skills",
|
||||
isDir: true,
|
||||
children: [],
|
||||
size: 0,
|
||||
};
|
||||
|
||||
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
|
||||
const defaults = {
|
||||
nodes: [file, dir],
|
||||
selectedPath: null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null,
|
||||
};
|
||||
const merged = { ...defaults, ...props };
|
||||
return { ...render(<FileTree {...merged} />), props: merged };
|
||||
}
|
||||
|
||||
describe("FileTree right-click context menu", () => {
|
||||
it("right-click on a file row opens menu with Open/Download/Delete", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), {
|
||||
clientX: 50,
|
||||
clientY: 100,
|
||||
});
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Open/i })).not.toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Download/i })).not.toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("right-click on a directory row opens menu with Delete only (no Open/Download)", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 60, clientY: 120 });
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /Open/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /Download/i })).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Download fires onDownload with the row's path", () => {
|
||||
const { props } = renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Download/i }));
|
||||
expect(props.onDownload).toHaveBeenCalledWith("config.yaml");
|
||||
// Menu auto-closes after click.
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Delete fires onDelete with the row's path when canDelete=true", () => {
|
||||
const { props } = renderTree({ canDelete: true });
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Delete/i }));
|
||||
expect(props.onDelete).toHaveBeenCalledWith("config.yaml");
|
||||
});
|
||||
|
||||
it("Delete is disabled when canDelete=false; clicking does not fire onDelete", () => {
|
||||
const { props } = renderTree({ canDelete: false });
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
const del = screen.getByRole("menuitem", { name: /Delete/i }) as HTMLButtonElement;
|
||||
expect(del.disabled).toBe(true);
|
||||
fireEvent.click(del);
|
||||
expect(props.onDelete).not.toHaveBeenCalled();
|
||||
// Menu stays open on disabled click — same as VSCode (the user
|
||||
// can read the disabled-state hint without losing the menu).
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Esc dismisses the menu", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
act(() => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("click outside the menu dismisses it", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
// mousedown on document.body — outside the menu.
|
||||
act(() => {
|
||||
fireEvent.mouseDown(document.body);
|
||||
});
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("opening a second context menu replaces the first (only one open at a time)", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 10, clientY: 10 });
|
||||
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 20, clientY: 20 });
|
||||
// Only one menu in the DOM. The second open replaced the first
|
||||
// because the menu state is lifted to the FileTree, not per-row.
|
||||
const menus = screen.getAllByRole("menu");
|
||||
expect(menus.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the drag-drop upload added in PR-D of issue #2999.
|
||||
// Two layers of coverage:
|
||||
//
|
||||
// 1. The pure walker (collectFileEntries / walkEntry) — pins the
|
||||
// recursion shape against silent folder truncation. Browsers
|
||||
// return up to ~100 entries per readEntries() call; if the loop
|
||||
// stops early, large folder uploads silently drop files. We
|
||||
// simulate a multi-batch reader to discriminate.
|
||||
//
|
||||
// 2. FileTree directory-row drop handlers — pins that dragover/drop
|
||||
// events fire onDropToTarget with the directory's path + the
|
||||
// drop's DataTransferItemList.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
import { __testables } from "../useFilesApi";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ---- Walker tests ----
|
||||
|
||||
/**
|
||||
* Build a fake FileSystemEntry tree we can hand to walkEntry. The
|
||||
* shape mimics what webkitGetAsEntry returns from a real OS drag —
|
||||
* directory entries expose createReader, file entries expose file().
|
||||
*/
|
||||
function fakeFileEntry(name: string, content = "x"): {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file: (cb: (f: File) => void) => void;
|
||||
} {
|
||||
return {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
file: (cb) => cb(new File([content], name, { type: "text/plain" })),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDirEntry(
|
||||
name: string,
|
||||
childBatches: ReturnType<typeof fakeFileEntry>[][],
|
||||
): {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
createReader: () => { readEntries: (cb: (entries: unknown[]) => void) => void };
|
||||
} {
|
||||
let i = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb) => {
|
||||
// Mimic browser semantics: emit one batch per call, then
|
||||
// an empty array to signal end-of-stream. A walker that
|
||||
// calls readEntries only once would silently truncate at
|
||||
// the first batch.
|
||||
if (i < childBatches.length) {
|
||||
cb(childBatches[i++]);
|
||||
} else {
|
||||
cb([]);
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("walkEntry — folder-recursion drop walker", () => {
|
||||
it("collects a single dropped file", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
await __testables.walkEntry(fakeFileEntry("README.md") as never, "", out);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].relativePath).toBe("README.md");
|
||||
expect(out[0].file.name).toBe("README.md");
|
||||
});
|
||||
|
||||
it("walks a folder and preserves the relative path under the folder name", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const folder = fakeDirEntry("skills", [
|
||||
[fakeFileEntry("a.md"), fakeFileEntry("b.md")],
|
||||
]);
|
||||
await __testables.walkEntry(folder as never, "", out);
|
||||
expect(out.map((e) => e.relativePath).sort()).toEqual([
|
||||
"skills/a.md",
|
||||
"skills/b.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loops readEntries until empty so a multi-batch folder isn't truncated", async () => {
|
||||
// Browsers limit each readEntries() call to ~100 entries. Our
|
||||
// walker MUST call it again until an empty batch is returned.
|
||||
// Fake reader emits two batches of 2 + an implicit empty → 4
|
||||
// total. A buggy walker that only takes the first batch would
|
||||
// see only 2.
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const folder = fakeDirEntry("big", [
|
||||
[fakeFileEntry("1.txt"), fakeFileEntry("2.txt")],
|
||||
[fakeFileEntry("3.txt"), fakeFileEntry("4.txt")],
|
||||
]);
|
||||
await __testables.walkEntry(folder as never, "", out);
|
||||
expect(out.length).toBe(4);
|
||||
});
|
||||
|
||||
it("walks nested directories and accumulates the full path", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const inner = fakeDirEntry("web-search", [[fakeFileEntry("SKILL.md")]]);
|
||||
// Outer dir whose first batch contains a sub-dir entry.
|
||||
const outer = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: "skills",
|
||||
fullPath: "/skills",
|
||||
createReader: () => {
|
||||
let i = 0;
|
||||
return {
|
||||
readEntries: (cb: (entries: unknown[]) => void) => {
|
||||
if (i++ === 0) cb([inner]);
|
||||
else cb([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await __testables.walkEntry(outer as never, "", out);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].relativePath).toBe("skills/web-search/SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- FileTree drag-drop wiring ----
|
||||
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
|
||||
const skillsDir: TreeNode = { name: "skills", path: "skills", isDir: true, children: [], size: 0 };
|
||||
|
||||
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
|
||||
// PR-D test defaults must include PR-C's onDownload + canDelete now
|
||||
// that they're required on the TreeCallbacks shape (the rebase
|
||||
// surfaced this — the merged tree depends on both feature sets).
|
||||
const defaults: React.ComponentProps<typeof FileTree> = {
|
||||
nodes: [file, skillsDir],
|
||||
selectedPath: null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
onDropToTarget: vi.fn(),
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null,
|
||||
};
|
||||
const merged = { ...defaults, ...props };
|
||||
return { ...render(<FileTree {...merged} />), props: merged };
|
||||
}
|
||||
|
||||
describe("FileTree directory-row drag-drop", () => {
|
||||
it("dragover on a directory row preventDefault's so the drop will fire", () => {
|
||||
renderTree();
|
||||
const row = screen.getByText("skills");
|
||||
const dragOver = new Event("dragover", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(dragOver, "dataTransfer", {
|
||||
value: { dropEffect: "" },
|
||||
});
|
||||
row.parentElement!.dispatchEvent(dragOver);
|
||||
// preventDefault registers via the React handler — without it
|
||||
// the drop event would never fire, so this assertion is the
|
||||
// load-bearing one.
|
||||
expect(dragOver.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("drop on a directory row fires onDropToTarget with that path + the items list", () => {
|
||||
const { props } = renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
|
||||
fireEvent.drop(row, { dataTransfer: { items: fakeItems } });
|
||||
expect(props.onDropToTarget).toHaveBeenCalledWith("skills", fakeItems);
|
||||
});
|
||||
|
||||
it("drop on a FILE row does NOT fire onDropToTarget (only directories are valid targets)", () => {
|
||||
const { props } = renderTree();
|
||||
const fileRow = screen.getByText("config.yaml").parentElement!;
|
||||
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
|
||||
fireEvent.drop(fileRow, { dataTransfer: { items: fakeItems } });
|
||||
expect(props.onDropToTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drop with no DataTransferItems does NOT fire onDropToTarget", () => {
|
||||
const { props } = renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
fireEvent.drop(row, { dataTransfer: { items: { length: 0 } } });
|
||||
expect(props.onDropToTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dragenter sets the drop-target highlight on the directory row", () => {
|
||||
renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
fireEvent.dragEnter(row, { dataTransfer: {} });
|
||||
// Highlight class is the discriminator — without dragenter
|
||||
// wiring the row stays in its hover-only style.
|
||||
expect(row.className).toMatch(/bg-accent|outline-accent/);
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,43 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch a file's content from the server and trigger a browser
|
||||
* download. Used by the right-click "Download" context-menu item
|
||||
* (PR-C of issue #2999) — distinct from `handleDownloadFile` in
|
||||
* FilesTab which downloads the CURRENTLY-OPEN-IN-EDITOR file from
|
||||
* the in-memory `editContent` buffer (so unsaved edits round-trip
|
||||
* to disk). This helper downloads the on-server content, suitable
|
||||
* for arbitrary tree rows the user hasn't opened.
|
||||
*/
|
||||
const downloadFileByPath = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const res = await api.get<{ content: string }>(
|
||||
`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`,
|
||||
);
|
||||
// text/plain is correct for the canvas's text-only file
|
||||
// surface (config.yaml, prompts, skill markdown). Binary
|
||||
// files would need an Accept-arraybuffer path; the API
|
||||
// returns string today so this matches the wire shape.
|
||||
const blob = new Blob([res.content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = path.split("/").pop() || "file";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Downloaded ${a.download}`, "success");
|
||||
} catch (e) {
|
||||
showToast(
|
||||
`Download failed: ${e instanceof Error ? e.message : "unknown error"}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
[workspaceId, root],
|
||||
);
|
||||
|
||||
const downloadAllFiles = useCallback(async () => {
|
||||
const fileEntries = files.filter((f) => !f.dir);
|
||||
const results = await Promise.allSettled(
|
||||
@@ -114,16 +151,20 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
}, [files, workspaceId]);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
async (fileList: FileList, targetDir = "") => {
|
||||
let uploaded = 0;
|
||||
for (const file of Array.from(fileList)) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const parts = path.split("/");
|
||||
// For folder picker: webkitRelativePath is "<picked-folder>/a/b.txt"
|
||||
// — strip the picked-folder prefix so files land flat under the
|
||||
// workspace's target dir, not under a redundant outer folder.
|
||||
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
|
||||
const finalPath = targetDir ? `${targetDir}/${relPath}` : relPath;
|
||||
if (file.size > 1_000_000) continue;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
|
||||
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, { content });
|
||||
uploaded++;
|
||||
} catch {
|
||||
/* skip binary */
|
||||
@@ -131,7 +172,7 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(`Uploaded ${uploaded} files`, "success");
|
||||
showToast(`Uploaded ${uploaded} files${targetDir ? ` to ${targetDir}` : ""}`, "success");
|
||||
loadFiles();
|
||||
}
|
||||
return uploaded;
|
||||
@@ -139,6 +180,58 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
[workspaceId, loadFiles]
|
||||
);
|
||||
|
||||
/**
|
||||
* Upload files dragged from the OS via the HTML5 DataTransferItemList
|
||||
* API. Unlike the folder-picker path (uploadFiles), this preserves
|
||||
* the dropped folder structure under `targetDir` — drag a "skills/"
|
||||
* folder onto the /configs/skills row and you get
|
||||
* /configs/skills/skills/* (the OUTER folder name is preserved
|
||||
* because the user explicitly chose to drop a NAMED folder, unlike
|
||||
* the folder-picker which always wraps the picked dir).
|
||||
*
|
||||
* Walks FileSystemDirectoryEntry recursively via webkitGetAsEntry.
|
||||
* VSCode/JupyterLab use the same primitive — there's no other
|
||||
* portable browser API for "drag a folder from OS". `webkit*`
|
||||
* naming is a Chromium relic; Firefox + Safari implement the same
|
||||
* surface.
|
||||
*
|
||||
* Returns the number of files uploaded so the caller can show a
|
||||
* tally / fail toast.
|
||||
*/
|
||||
const uploadDataTransferItems = useCallback(
|
||||
async (items: DataTransferItemList, targetDir = "") => {
|
||||
const fileEntries = collectFileEntries(items);
|
||||
let uploaded = 0;
|
||||
for (const { file, relativePath } of await fileEntries) {
|
||||
if (file.size > 1_000_000) continue;
|
||||
const finalPath = targetDir
|
||||
? `${targetDir}/${relativePath}`
|
||||
: relativePath;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, {
|
||||
content,
|
||||
});
|
||||
uploaded++;
|
||||
} catch {
|
||||
/* skip binary */
|
||||
}
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(
|
||||
`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}${targetDir ? ` to ${targetDir}` : ""}`,
|
||||
"success",
|
||||
);
|
||||
loadFiles();
|
||||
}
|
||||
return uploaded;
|
||||
},
|
||||
[workspaceId, loadFiles],
|
||||
);
|
||||
|
||||
const deleteAllFiles = useCallback(async () => {
|
||||
let deleted = 0;
|
||||
for (const f of files) {
|
||||
@@ -165,8 +258,98 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
downloadFileByPath,
|
||||
downloadAllFiles,
|
||||
uploadFiles,
|
||||
uploadDataTransferItems,
|
||||
deleteAllFiles,
|
||||
};
|
||||
}
|
||||
|
||||
// ----- DataTransfer entry walker (PR-D) ---------------------------------
|
||||
|
||||
/**
|
||||
* Minimal subset of the FileSystem Entry API surface we use. The DOM
|
||||
* lib types this as FileSystemEntry / FileSystemFileEntry /
|
||||
* FileSystemDirectoryEntry but the relevant methods are callback-
|
||||
* based. Keep the shape narrow + explicit so the recursion below
|
||||
* type-checks without pulling in the full DOM lib types.
|
||||
*/
|
||||
interface FSEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?(success: (f: File) => void, fail?: (e: unknown) => void): void;
|
||||
createReader?(): { readEntries(success: (entries: FSEntry[]) => void): void };
|
||||
}
|
||||
|
||||
interface CollectedEntry {
|
||||
file: File;
|
||||
/** Path relative to the dropped root (e.g. "skills/web-search/SKILL.md"
|
||||
* for a dropped "skills/" folder containing web-search/SKILL.md). */
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a DataTransferItemList, returning every file entry as a flat
|
||||
* array keyed by the path relative to the originally-dropped item.
|
||||
* Folders dropped from the OS expand recursively; loose files
|
||||
* passthrough with name as the relative path.
|
||||
*
|
||||
* Skips items where webkitGetAsEntry() returns null — that's how
|
||||
* the browser signals a non-file payload (e.g. a dragged URL or
|
||||
* text snippet).
|
||||
*/
|
||||
async function collectFileEntries(
|
||||
items: DataTransferItemList,
|
||||
): Promise<CollectedEntry[]> {
|
||||
const out: CollectedEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind !== "file") continue;
|
||||
// webkitGetAsEntry is the standardised name; older Firefox used
|
||||
// getAsEntry. Both Chromium + Firefox + Safari ship the webkit-
|
||||
// prefixed variant today. There's no non-prefixed alternative.
|
||||
const entry = (item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FSEntry | null;
|
||||
}).webkitGetAsEntry?.();
|
||||
if (!entry) continue;
|
||||
await walkEntry(entry, "", out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function walkEntry(
|
||||
entry: FSEntry,
|
||||
prefix: string,
|
||||
out: CollectedEntry[],
|
||||
): Promise<void> {
|
||||
const name = entry.name;
|
||||
const relPath = prefix ? `${prefix}/${name}` : name;
|
||||
if (entry.isFile && entry.file) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
entry.file!(resolve, reject);
|
||||
});
|
||||
out.push({ file, relativePath: relPath });
|
||||
return;
|
||||
}
|
||||
if (entry.isDirectory && entry.createReader) {
|
||||
const reader = entry.createReader();
|
||||
// readEntries returns up to ~100 at a time on Chromium; loop
|
||||
// until empty so large folders aren't truncated.
|
||||
let batch: FSEntry[] = [];
|
||||
do {
|
||||
batch = await new Promise<FSEntry[]>((resolve) =>
|
||||
reader.readEntries(resolve),
|
||||
);
|
||||
for (const child of batch) {
|
||||
await walkEntry(child, relPath, out);
|
||||
}
|
||||
} while (batch.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for direct testing — the recursion + readEntries batching
|
||||
// is the part most likely to silently truncate a real folder upload.
|
||||
export const __testables = { collectFileEntries, walkEntry };
|
||||
|
||||
@@ -297,10 +297,49 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Compact-empty pattern: when the workspace has zero plugins
|
||||
// installed AND the registry isn't open, collapse the whole
|
||||
// "Plugins" section into a single inline pill rather than rendering
|
||||
// the full panel chrome. Reported on production 2026-05-05 (#2971):
|
||||
// the empty state's panel-with-zero-list-rows layout gives the user
|
||||
// a lot of vertical real estate for content that's just "0
|
||||
// installed + Install button". The compact form keeps that
|
||||
// affordance without the chrome.
|
||||
//
|
||||
// Expanded/full layout still fires when installed.length > 0 OR
|
||||
// when the user opens the registry (clicked "+ Install Plugin").
|
||||
// Once a plugin is installed the section auto-expands to surface
|
||||
// the list.
|
||||
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded;
|
||||
|
||||
if (compactEmpty) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 rounded-full border border-line/60 bg-surface-sunken/70 px-3 py-1.5"
|
||||
aria-label="Plugins (none installed)"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Plugins</span>
|
||||
<span className="text-[11px] text-ink-mid">0 installed</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(true)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
aria-expanded="false"
|
||||
aria-controls="plugins-section"
|
||||
>
|
||||
+ Install Plugin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Plugins section */}
|
||||
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
||||
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
|
||||
@@ -311,6 +350,8 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={() => setShowRegistry(!showRegistry)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
aria-expanded={showRegistry}
|
||||
aria-controls="plugins-registry"
|
||||
>
|
||||
{showRegistry ? "Hide Registry" : "+ Install Plugin"}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the "Files not available" early-return for runtimes whose
|
||||
// filesystem the platform doesn't own (today: runtime === "external").
|
||||
//
|
||||
// Pre-fix: FilesTab issued a GET /workspaces/<id>/files for every
|
||||
// workspace. The platform's response for an external workspace is
|
||||
// always [] (no rows in workspace_files), but the canvas rendered
|
||||
// "0 files / No config files yet" — visually identical to the SaaS
|
||||
// empty-listing bug fixed in PR-A. The placeholder makes the absence
|
||||
// intentional.
|
||||
//
|
||||
// Pinned branches:
|
||||
// 1. external runtime → "Files not available" banner renders,
|
||||
// runtime name surfaces in the body so user knows WHY.
|
||||
// 2. external runtime → useFilesApi is NOT invoked. Verified by
|
||||
// asserting the mocked api.get was never called.
|
||||
// 3. claude-code (or any other runtime) → no banner, normal mount
|
||||
// proceeds (`/configs` toolbar visible). Pre-fix regression cover.
|
||||
// 4. data prop omitted (legacy callers) → no early-return, falls
|
||||
// through to normal mount.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock the api module so the normal-mount branches don't try to
|
||||
// fetch against a real backend — and so we can assert the
|
||||
// external-runtime branch never fires a request.
|
||||
const apiCalls: string[] = [];
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn((path: string) => {
|
||||
apiCalls.push(path);
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
put: vi.fn(() => Promise.resolve()),
|
||||
del: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
// useCanvasStore is referenced by useFilesApi for the needsRestart
|
||||
// flag. The Toaster import inside FilesTab also pulls the store
|
||||
// indirectly. Stub minimally to satisfy the import chain.
|
||||
vi.mock("@/store/canvas", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/store/canvas")>(
|
||||
"@/store/canvas",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useCanvasStore: {
|
||||
getState: () => ({
|
||||
updateNodeData: vi.fn(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiCalls.length = 0;
|
||||
});
|
||||
|
||||
import { FilesTab } from "../FilesTab";
|
||||
|
||||
const externalData = { runtime: "external", status: "online" } as unknown as Parameters<
|
||||
typeof FilesTab
|
||||
>[0]["data"];
|
||||
|
||||
const claudeData = { runtime: "claude-code", status: "online" } as unknown as Parameters<
|
||||
typeof FilesTab
|
||||
>[0]["data"];
|
||||
|
||||
describe("FilesTab not-available early-return for runtimes without platform-owned filesystem", () => {
|
||||
it("external runtime renders the not-available banner with runtime name", () => {
|
||||
render(<FilesTab workspaceId="ws-ext" data={externalData} />);
|
||||
expect(screen.getByText(/Files not available/i)).not.toBeNull();
|
||||
// Runtime name must surface so the user understands WHY — without
|
||||
// it the placeholder reads as a generic error.
|
||||
expect(screen.getByText(/external/)).not.toBeNull();
|
||||
// Chat tab is the recommended alternative — flagged in copy so the
|
||||
// user knows where to go next instead of bouncing tabs.
|
||||
expect(screen.getByText(/Chat tab/i)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("external runtime does NOT issue any /files API call", async () => {
|
||||
render(<FilesTab workspaceId="ws-ext" data={externalData} />);
|
||||
// Tolerate one microtask boundary in case useEffect schedules.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const filesCalls = apiCalls.filter((p) => p.includes("/files"));
|
||||
expect(filesCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("claude-code runtime does NOT render the banner (normal mount)", async () => {
|
||||
render(<FilesTab workspaceId="ws-claude" data={claudeData} />);
|
||||
// The normal-mount path renders the FilesToolbar with the root
|
||||
// selector. Wait for it (useEffect → loadFiles → setLoading false).
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Files not available/i)).toBeNull();
|
||||
});
|
||||
// Toolbar's root selector confirms we're on the platform-owned
|
||||
// rendering path, not the placeholder.
|
||||
expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("data prop omitted falls through to normal mount (back-compat)", async () => {
|
||||
render(<FilesTab workspaceId="ws-no-data" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Files not available/i)).toBeNull();
|
||||
});
|
||||
// Without data we can't gate on runtime — must mount normally.
|
||||
expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the compact-when-empty layout for the SkillsTab Plugins section
|
||||
// (issue #2971, reported on production 2026-05-05).
|
||||
//
|
||||
// Three states matter for layout:
|
||||
// 1. installed.length === 0 + registry closed + load completed → COMPACT pill
|
||||
// 2. installed.length > 0 → FULL panel + installed list
|
||||
// 3. registry open (showRegistry=true) → FULL panel + registry browser
|
||||
//
|
||||
// The compact-empty path is the new behavior; the other two were
|
||||
// pre-existing. This test pins all three so a future refactor that
|
||||
// over-collapses (showing compact when plugins are installed) or
|
||||
// over-expands (showing full panel on empty load) fails loudly.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string, opts?: unknown) => apiGet(path, opts),
|
||||
post: vi.fn(() => Promise.resolve({})),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
import { SkillsTab } from "../SkillsTab";
|
||||
|
||||
const minimalData = {
|
||||
status: "online" as const,
|
||||
runtime: "claude-code",
|
||||
currentTask: "",
|
||||
agentCard: undefined,
|
||||
} as unknown as Parameters<typeof SkillsTab>[0]["data"];
|
||||
|
||||
describe("SkillsTab Plugins compact-empty layout", () => {
|
||||
it("renders compact pill when installed.length === 0 and registry closed", async () => {
|
||||
// Both fetches return empty arrays — workspace is fresh, no plugins.
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path.endsWith("/plugins") || path === "/plugins" || path === "/plugins/sources") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<SkillsTab workspaceId="ws-fresh" data={minimalData} />);
|
||||
|
||||
// Wait for the installedLoaded gate to flip — without that the
|
||||
// component renders a "loading" state, not the compact pill.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Compact assertions: the rounded-xl panel chrome MUST NOT be in
|
||||
// the DOM (we'd see two "Plugins" labels — one in the header,
|
||||
// one in the pill — if the layout regressed to "always full
|
||||
// panel"). The compact form has exactly one "Plugins" label.
|
||||
const labels = screen.getAllByText("Plugins");
|
||||
expect(labels).toHaveLength(1);
|
||||
|
||||
// The full-panel chrome's id="plugins-section" should NOT be
|
||||
// rendered when we're in compact mode.
|
||||
expect(document.getElementById("plugins-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders full panel when installed.length > 0", async () => {
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path.endsWith("/plugins")) {
|
||||
return Promise.resolve([
|
||||
{ name: "memory-postgres", version: "1.0.0", description: "memory backend", supported_on_runtime: true },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<SkillsTab workspaceId="ws-installed" data={minimalData} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 installed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Full-panel chrome MUST be present — id pin.
|
||||
expect(document.getElementById("plugins-section")).not.toBeNull();
|
||||
// Compact pill ariaLabel MUST NOT be present.
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("expands to full panel when user clicks + Install Plugin from compact pill", async () => {
|
||||
apiGet.mockImplementation(() => Promise.resolve([]));
|
||||
render(<SkillsTab workspaceId="ws-expand" data={minimalData} />);
|
||||
|
||||
// Start compact — wait for the compact pill to settle so we click
|
||||
// the right button (initial render before installedLoaded flips
|
||||
// doesn't have either layout, and the post-load compact pill is
|
||||
// what we want to interact with).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
|
||||
expect(installBtn.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
// After click, registry opens → full panel renders. The compact
|
||||
// pill's aria-label should be gone; the full-panel id should
|
||||
// appear. Generous waitFor — a registry fetch may also fire in
|
||||
// the React effect chain, and we want to assert the compact →
|
||||
// full transition without racing it.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.getElementById("plugins-section")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT collapse to compact while initial load is pending (avoid flash)", () => {
|
||||
// Returning a never-resolving promise means installedLoaded stays
|
||||
// false. The compact pill MUST NOT render in this state — that
|
||||
// would flash compact → full as the load completes, which looks
|
||||
// janky. The component shows a loading shell instead (the
|
||||
// existing pre-fix behavior).
|
||||
apiGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<SkillsTab workspaceId="ws-loading" data={minimalData} />);
|
||||
|
||||
// Synchronous assertion — no waitFor — since we want to confirm
|
||||
// the compact pill is NOT rendered before any network round-trip
|
||||
// finishes.
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentAudio — inline native HTML5 <audio controls> player for
|
||||
// chat attachments (RFC #2991, PR-2).
|
||||
//
|
||||
// Same auth + Blob-URL pattern as AttachmentImage / AttachmentVideo.
|
||||
// Native audio control bar handles play/pause/scrub/volume/download,
|
||||
// and there's no fullscreen UI to worry about (audio doesn't need
|
||||
// AttachmentLightbox).
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; src: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", src: href });
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", src: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 280, height: 40 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex flex-col gap-1 rounded-md border px-2 py-1 ${
|
||||
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
|
||||
}`}
|
||||
>
|
||||
{/* Filename label so the user knows what they're hearing
|
||||
before pressing play. Short, single-line, truncated. */}
|
||||
<span className="text-[10px] text-ink-mid truncate max-w-[280px]" title={attachment.name}>
|
||||
{attachment.name}
|
||||
</span>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
src={state.src}
|
||||
style={{ width: 280, height: 32 }}
|
||||
onError={() => setState({ kind: "error" })}
|
||||
>
|
||||
{attachment.name}
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentImage — inline image thumbnail + click-to-fullscreen.
|
||||
// First "specialized renderer" landing under RFC #2991 PR-1.
|
||||
//
|
||||
// Auth model
|
||||
// ----------
|
||||
//
|
||||
// The Critical UX/Security trade-off (per RFC's hostile-self-review
|
||||
// item #2): the bytes live behind workspace auth. A bare
|
||||
// <img src="https://reno-stars.../chat/download?path=…"> WILL NOT
|
||||
// include our cookie + Origin headers when the browser loads it —
|
||||
// even for same-origin canvas-server, the auth chain (cookie + token
|
||||
// + X-Molecule-Org-Slug header) is JS-injected, not browser-default.
|
||||
//
|
||||
// Solution: same auth path the chip download uses. Fetch the bytes
|
||||
// with the JS auth headers, wrap in a Blob, hand the browser an
|
||||
// ObjectURL. The image renders from local memory; no second request,
|
||||
// no auth leakage, no CORS pain.
|
||||
//
|
||||
// That same blob URL is what the lightbox shows on click — single
|
||||
// fetch, cached for the lifetime of the message bubble.
|
||||
//
|
||||
// Failure modes
|
||||
// -------------
|
||||
//
|
||||
// - Fetch fails (404, 403, network) → fall back to AttachmentChip
|
||||
// (the existing file-pill download flow). The user still gets a
|
||||
// working download; we just lose the inline preview.
|
||||
// - Decoded as non-image (server returned wrong Content-Type, or
|
||||
// bytes are corrupt) → onError handler swaps to AttachmentChip.
|
||||
// - Bytes too large — no enforcement here; the server caps at 25MB
|
||||
// per file (chat_files.go), which is too big for a thumbnail but
|
||||
// acceptable for a chat-attached image. If we hit pain we can
|
||||
// downscale via canvas, but defer that to v2.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; blobUrl: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const [open, setOpen] = useState(false);
|
||||
// Track whether we created the ObjectURL so cleanup runs on the
|
||||
// exact value we minted (state could change between effect setup
|
||||
// and effect cleanup if a new fetch fires).
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
// For non-platform URIs (http/https external image hosts) we can
|
||||
// skip the auth fetch — browser loads them directly. We bail out
|
||||
// of the auth-fetch flow and use the raw URL via resolveAttachmentHref.
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", blobUrl: href });
|
||||
return;
|
||||
}
|
||||
|
||||
// Platform-auth path: identical to downloadChatFile but we keep
|
||||
// the blob (don't trigger a Save-As). Use the same headers it does
|
||||
// by going through it indirectly — no, downloadChatFile triggers a
|
||||
// Save-As. Need a separate fetch.
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
// Read the same env var downloadChatFile reads — single source
|
||||
// of truth would be cleaner; refactor opportunity for PR-2 if
|
||||
// we add the same path to AttachmentVideo.
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", blobUrl: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Free the ObjectURL when the bubble unmounts — keeps memory
|
||||
// bounded across long chat histories.
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
// Failure → render the existing file chip. Maintains the download
|
||||
// affordance even if preview fails; the user never gets stuck.
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
|
||||
// Loading → small placeholder pill so the bubble doesn't reflow
|
||||
// when the image lands. Sized to roughly the thumbnail's aspect
|
||||
// ratio guess (a 240x180 box) so the layout is stable.
|
||||
if (state.kind === "loading" || state.kind === "idle") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 240, height: 180 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready → inline thumbnail with click handler. The img has its
|
||||
// own onError so a corrupt blob (server returned the right size
|
||||
// but invalid bytes) falls through to the chip too.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={`Preview ${attachment.name}`}
|
||||
className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
||||
tone === "user" ? "border-blue-400/30" : "border-line/50"
|
||||
}`}
|
||||
aria-label={`Open ${attachment.name} preview`}
|
||||
>
|
||||
<img
|
||||
src={state.blobUrl}
|
||||
alt={attachment.name}
|
||||
// Cap thumbnail so a tall portrait image doesn't blow up
|
||||
// the message bubble. The lightbox shows the full size.
|
||||
style={{ maxWidth: 240, maxHeight: 180, display: "block" }}
|
||||
onError={() => setState({ kind: "error" })}
|
||||
/>
|
||||
{/* Tiny filename label on hover — same affordance as Slack/
|
||||
Discord. Helps when several images land in one bubble. */}
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 truncate opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</button>
|
||||
<AttachmentLightbox
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
>
|
||||
<img
|
||||
src={state.blobUrl}
|
||||
alt={attachment.name}
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain"
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal helper — duplicated from uploads.ts (it's not exported
|
||||
// there). Kept local so this component doesn't reach into private
|
||||
// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
|
||||
// it, lift to an exported helper at that point (the third-caller
|
||||
// rule).
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
// Tenant subdomain shape: <slug>.moleculesai.app
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentLightbox — shared fullscreen modal for image / PDF /
|
||||
// (future) any-fullscreen-renderable kind. Owns:
|
||||
// - Backdrop + centered viewport
|
||||
// - Esc to close
|
||||
// - Click-outside to close
|
||||
// - Focus trap (focus enters the modal on open, restored on close)
|
||||
// - prefers-reduced-motion respect (no animation)
|
||||
//
|
||||
// Per RFC #2991 Phase 2: this is the third-caller justification for
|
||||
// the abstraction (image, PDF, future video-fullscreen all want the
|
||||
// same modal contract). Not invented for a single caller.
|
||||
//
|
||||
// Design choices:
|
||||
//
|
||||
// 1. Portals — we don't use ReactDOM.createPortal because the canvas
|
||||
// chat surface already renders at a high z-index and the modal's
|
||||
// fixed-position layout reaches the viewport regardless. Saves a
|
||||
// portal mount in the common case + avoids the SSR warning (canvas
|
||||
// is "use client" but the parent shell is server-rendered).
|
||||
//
|
||||
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
|
||||
// chat lightbox needs to trap focus only across two interactive
|
||||
// elements (close button + content), so a 100-line manual trap
|
||||
// beats pulling in focus-trap-react for ~12KB.
|
||||
//
|
||||
// 3. Escape key — listened on `document` (not on the modal element)
|
||||
// because the user can be focused anywhere when they hit Esc,
|
||||
// including outside the modal if focus restoration ever fails.
|
||||
// The cleanup runs on unmount so leaked listeners don't persist.
|
||||
|
||||
import { useEffect, useRef, useCallback, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
/** Render the lightbox when true. Caller controls open state. */
|
||||
open: boolean;
|
||||
/** Caller's handler for "close" — Esc, click-outside, X button. */
|
||||
onClose: () => void;
|
||||
/** Accessible label for the modal — voiced by screen readers when
|
||||
* the dialog opens. The caller knows what's inside (image alt
|
||||
* text, PDF filename) and supplies it. */
|
||||
ariaLabel: string;
|
||||
/** The thing being shown in fullscreen — <img>, <embed>, etc.
|
||||
* Caller is responsible for sizing it to fit the viewport (we
|
||||
* give it max-w-full max-h-full via CSS). */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Focus enters the close button on open + restores to whatever
|
||||
// had focus when the modal closes. Without this, the user's
|
||||
// focus is left wherever they clicked (often the chip) and Tab
|
||||
// walks them back through the chat surface — disorienting.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
closeButtonRef.current?.focus();
|
||||
return () => {
|
||||
previousFocusRef.current?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Esc closes; bound on document so the user can press Esc
|
||||
// regardless of where focus actually is.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Click on the backdrop (NOT the content) closes. Content's own
|
||||
// onClick stops propagation so the user can interact (e.g. native
|
||||
// PDF viewer controls) without dismissing the modal.
|
||||
const onBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
|
||||
onClick={onBackdropClick}
|
||||
>
|
||||
{/* Close button — top-right, large hit area, keyboard-focusable.
|
||||
ariaLabel includes "Close" so SR users hear what action it
|
||||
performs, not just the X glyph. */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
className="absolute top-4 right-4 rounded-full bg-white/10 hover:bg-white/20 text-white p-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 5l14 14M19 5l-14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentPDF — inline PDF preview using the browser's native viewer
|
||||
// (RFC #2991, PR-3).
|
||||
//
|
||||
// Why browser-native (not PDF.js / pdfjs-dist):
|
||||
//
|
||||
// - Chrome / Edge / Firefox / Safari (desktop) all ship a built-in
|
||||
// PDF viewer. <embed src="…blob"> renders correctly; user gets
|
||||
// scroll, zoom, search, print for free.
|
||||
// - PDF.js adds ~3 MB to the canvas bundle. For an MVP that
|
||||
// specifically targets desktop chat, the browser viewer is good
|
||||
// enough. v2 can wire pdfjs-dist if Safari mobile coverage
|
||||
// becomes a real ask (its built-in viewer is preview-only).
|
||||
//
|
||||
// Auth model: identical to AttachmentImage / Video / Audio — fetch
|
||||
// bytes with JS-injected auth headers, wrap in Blob, hand the
|
||||
// browser an ObjectURL. <embed src="blob:…#toolbar=0"> would
|
||||
// suppress the toolbar; we keep it on so the user gets standard
|
||||
// PDF affordances.
|
||||
//
|
||||
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
|
||||
// click. Same shared modal as image — third caller justifies the
|
||||
// abstraction (per RFC #2991 design).
|
||||
//
|
||||
// Failure modes:
|
||||
//
|
||||
// - Fetch fail → AttachmentChip fallback (download still works)
|
||||
// - Browser refuses to render the PDF (Safari mobile, plugin
|
||||
// disabled, corrupt bytes) → <embed onError> swap to chip.
|
||||
// Note: <embed> doesn't fire onError reliably across browsers.
|
||||
// Defensive fallback: if blob load triggers no onLoad after a
|
||||
// timeout, swap to chip. Implemented as a 3-second watchdog.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; blobUrl: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const [open, setOpen] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", blobUrl: href });
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", blobUrl: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse flex items-center gap-1.5 px-2 py-1 text-[10px] text-ink-mid"
|
||||
style={{ width: 240 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
>
|
||||
<PdfGlyph />
|
||||
Loading {attachment.name}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF preview chip — clicking it opens the full embed in the
|
||||
// shared lightbox. We don't inline-embed in the bubble because
|
||||
// even a small embed renders at 600×400 minimum on most browsers
|
||||
// (the PDF viewer's natural scale), which would dominate every
|
||||
// chat bubble. Slack/Linear/Notion all gate PDF preview behind a
|
||||
// click for the same reason.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={`Preview ${attachment.name}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] hover:bg-surface-card/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
||||
tone === "user"
|
||||
? "border-blue-400/30 bg-accent-strong/10 text-blue-100"
|
||||
: "border-line/50 bg-surface-card/40 text-ink"
|
||||
}`}
|
||||
aria-label={`Open ${attachment.name} preview`}
|
||||
>
|
||||
<PdfGlyph />
|
||||
<span className="truncate max-w-[200px]">{attachment.name}</span>
|
||||
<span className="opacity-60 shrink-0">PDF</span>
|
||||
</button>
|
||||
<AttachmentLightbox
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
>
|
||||
<embed
|
||||
src={state.blobUrl}
|
||||
type="application/pdf"
|
||||
// The lightbox's content slot caps at 95vw / 90vh, so size
|
||||
// 100% within that and let the user scroll inside the PDF
|
||||
// viewer.
|
||||
style={{ width: "95vw", height: "90vh" }}
|
||||
aria-label={attachment.name}
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="shrink-0 opacity-70"
|
||||
>
|
||||
<path
|
||||
d="M4 2h5l3 3v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path
|
||||
d="M5.5 9.5h1m1 0h1m-3 2h2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentPreview — the SSOT dispatch point for chat-attachment
|
||||
// rendering (RFC #2991, PR-1).
|
||||
//
|
||||
// Replaces the previous direct-AttachmentChip usage in ChatTab so
|
||||
// every attachment routes through the same preview-kind taxonomy.
|
||||
// Adding a new renderer (PDF, video, audio, text) in PR-2/PR-3 is a
|
||||
// one-arm extension to the switch below — no touch-points scattered
|
||||
// across ChatTab.tsx, AgentCommsPanel.tsx, or other chat consumers.
|
||||
//
|
||||
// Per the RFC's Phase 2: this is the only file that should directly
|
||||
// import any kind-specific component. ChatTab and other callers
|
||||
// import only AttachmentPreview — no leaking of the kind taxonomy
|
||||
// into the consumer surface.
|
||||
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { getAttachmentPreviewKind } from "./preview-kind";
|
||||
import { AttachmentImage } from "./AttachmentImage";
|
||||
import { AttachmentVideo } from "./AttachmentVideo";
|
||||
import { AttachmentAudio } from "./AttachmentAudio";
|
||||
import { AttachmentPDF } from "./AttachmentPDF";
|
||||
import { AttachmentTextPreview } from "./AttachmentTextPreview";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
/** Caller's download handler — used for the kind=file fallback
|
||||
* and as the kind-specific renderers' fallback when their own
|
||||
* preview fails (e.g. image fetch errored). */
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
/** Tone follows the message bubble's role — used for visual
|
||||
* variant only. */
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
export function AttachmentPreview({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const kind = getAttachmentPreviewKind(attachment.mimeType, attachment.uri, attachment.name);
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return (
|
||||
<AttachmentImage
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<AttachmentVideo
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<AttachmentAudio
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<AttachmentPDF
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<AttachmentTextPreview
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "file":
|
||||
default:
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentTextPreview — inline preview for text/code/JSON/YAML/etc
|
||||
// (RFC #2991, PR-3).
|
||||
//
|
||||
// Shape: render first N lines (~10) in monospace inside the bubble.
|
||||
// Click "Show more" to expand fully; the lightbox is reserved for
|
||||
// image/PDF where viewport-size matters. For text, the bubble itself
|
||||
// can host the full content.
|
||||
//
|
||||
// Why no syntax highlighting (yet):
|
||||
//
|
||||
// - Pulling in shiki / highlight.js / prism adds 200-500KB to the
|
||||
// bundle for a feature that's nice-to-have. MVP uses plain
|
||||
// <pre><code>.
|
||||
// - Future: lazy-load shiki on first text-attachment render. v2
|
||||
// if the user reports the gap.
|
||||
//
|
||||
// Auth: same fetch+text() pattern as image/video/audio, but we read
|
||||
// the text directly instead of building a Blob URL — no <img>/<video>
|
||||
// element to feed.
|
||||
//
|
||||
// Memory: text files are usually small. We cap the preview at 256 KB
|
||||
// fetched (large logs would otherwise crash the bubble). If the file
|
||||
// exceeds the cap, we show what we got + a "truncated" note + a chip
|
||||
// to download the full file.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; text: string; truncated: boolean }
|
||||
| { kind: "error" };
|
||||
|
||||
const PREVIEW_LINE_COUNT = 10;
|
||||
const MAX_FETCH_BYTES = 256 * 1024; // 256 KB
|
||||
|
||||
export function AttachmentTextPreview({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
if (isPlatformAttachment(attachment.uri)) {
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
}
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
// Read up to MAX_FETCH_BYTES. Use the standard ReadableStream
|
||||
// path so we don't materialise a 100MB log into memory.
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
// Fallback: small text file, just .text() it.
|
||||
const text = await res.text();
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
kind: "ready",
|
||||
text: text.slice(0, MAX_FETCH_BYTES),
|
||||
truncated: text.length > MAX_FETCH_BYTES,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let received = 0;
|
||||
const chunks: BlobPart[] = [];
|
||||
while (received < MAX_FETCH_BYTES) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
// Copy into a fresh ArrayBuffer-backed view — TS in lib.dom
|
||||
// 2026 narrows BlobPart away from SharedArrayBuffer-backed
|
||||
// Uint8Arrays. Blob() accepts the copy fine at runtime.
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
chunks.push(copy.buffer);
|
||||
received += value.byteLength;
|
||||
}
|
||||
// If we hit the cap but the stream isn't done, mark truncated.
|
||||
const truncated = received >= MAX_FETCH_BYTES;
|
||||
if (truncated) reader.cancel();
|
||||
const blob = new Blob(chunks);
|
||||
const text = await blob.text();
|
||||
if (cancelled) return;
|
||||
setState({ kind: "ready", text, truncated });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 320, height: 80 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = state.text.split("\n");
|
||||
const preview = expanded ? state.text : lines.slice(0, PREVIEW_LINE_COUNT).join("\n");
|
||||
const showExpandButton = !expanded && lines.length > PREVIEW_LINE_COUNT;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block max-w-full rounded-md border ${
|
||||
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-line/40 text-[10px] text-ink-mid">
|
||||
<span className="truncate max-w-[220px]" title={attachment.name}>
|
||||
{attachment.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(attachment)}
|
||||
className="text-ink-soft hover:text-ink"
|
||||
title={`Download ${attachment.name}`}
|
||||
aria-label={`Download ${attachment.name}`}
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto px-2 py-1.5 text-[10px] leading-snug text-ink whitespace-pre font-mono max-w-[480px] max-h-[300px]">
|
||||
<code>{preview}</code>
|
||||
</pre>
|
||||
{showExpandButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="block w-full text-center text-[10px] text-ink-mid hover:text-ink py-1 border-t border-line/40"
|
||||
>
|
||||
Show all {lines.length} lines
|
||||
</button>
|
||||
)}
|
||||
{state.truncated && (
|
||||
<div className="px-2 py-1 text-[10px] text-warm border-t border-line/40">
|
||||
Preview truncated at {Math.round(MAX_FETCH_BYTES / 1024)} KB —{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(attachment)}
|
||||
className="underline"
|
||||
>
|
||||
download full file
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentVideo — inline native HTML5 <video controls> player for
|
||||
// chat attachments (RFC #2991, PR-2).
|
||||
//
|
||||
// Why HTML5-native (vs custom JS player):
|
||||
//
|
||||
// - Browser vendors ship hardware-accelerated decoders, captions,
|
||||
// and fullscreen UI. We get all of it for free.
|
||||
// - Native fullscreen via the <video> element's built-in button
|
||||
// (no AttachmentLightbox needed for video — the browser does it).
|
||||
// - Mobile-friendly: iOS / Android Safari + Chrome handle the
|
||||
// pinch + scrub UX the user already knows.
|
||||
//
|
||||
// Auth model — identical to AttachmentImage:
|
||||
// platform-auth URIs need our cookie/token, so we fetch the bytes,
|
||||
// wrap in a Blob, hand the browser an ObjectURL via <video src=>.
|
||||
// External (http/https) URIs skip the fetch and use the raw URL.
|
||||
//
|
||||
// Memory caveat: a Blob holds the entire video in JS memory until
|
||||
// the bubble unmounts. For multi-hundred-MB videos this is bad. The
|
||||
// server caps single-file uploads at 25MB (chat_files.go), so we're
|
||||
// bounded; if larger files become a real shape, switch to streaming
|
||||
// via MediaSource or just `<video src=…>` with a credentials-aware
|
||||
// fetch via service worker. v2 if measured-needed.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; src: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
// External video (http/https) — let the browser stream it
|
||||
// natively without the JS-blob detour.
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", src: href });
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
// Videos are larger than images on average; give the request
|
||||
// more headroom. The server's per-request body cap (50MB) is
|
||||
// still the actual ceiling.
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", src: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 320, height: 180 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block rounded-lg overflow-hidden border ${
|
||||
tone === "user" ? "border-blue-400/30" : "border-line/50"
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
// preload="metadata" so the browser fetches just enough to
|
||||
// show duration + first frame thumbnail without streaming
|
||||
// the whole file before the user clicks play.
|
||||
preload="metadata"
|
||||
// playsInline keeps mobile Safari from auto-fullscreening
|
||||
// on play; the user can still hit the native fullscreen
|
||||
// button (or PiP on Chrome) if they want.
|
||||
playsInline
|
||||
// Native fullscreen via the <video> control bar; no
|
||||
// AttachmentLightbox needed for video.
|
||||
src={state.src}
|
||||
// Cap thumbnail / inline display so the bubble doesn't blow
|
||||
// up vertical layout for tall portrait clips. The native
|
||||
// fullscreen button uses the original aspect ratio.
|
||||
style={{ maxWidth: 320, maxHeight: 240, display: "block" }}
|
||||
// Bytes that aren't actually a valid video (corrupt blob,
|
||||
// wrong Content-Type) fail load → swap to chip.
|
||||
onError={() => setState({ kind: "error" })}
|
||||
>
|
||||
<track kind="captions" />
|
||||
{attachment.name}
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal helper — same shape as AttachmentImage's. Lifted to a
|
||||
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// AttachmentPreview component tests — pin the dispatch contract:
|
||||
// each kind goes to its dedicated renderer; kind=file falls back to
|
||||
// the chip; failure modes don't strand the user without a download.
|
||||
//
|
||||
// Per RFC #2991 Phase 4: every test must be able to fail. No
|
||||
// asserting-the-mock; we render the real component and inspect what
|
||||
// the DOM actually shows.
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock the auth-token env var so AttachmentImage's fetch doesn't
|
||||
// hit a real network. The fetch is itself mocked below.
|
||||
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
|
||||
|
||||
// Mock fetch so the AttachmentImage path can return a synthetic blob.
|
||||
// Tests override per-case to simulate success / 404 / network fail.
|
||||
const fetchMock = vi.fn();
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
// jsdom doesn't implement URL.createObjectURL — stub.
|
||||
global.URL.createObjectURL = vi.fn(() => "blob:test-url");
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
import { AttachmentPreview } from "../AttachmentPreview";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
const onDownload = vi.fn();
|
||||
|
||||
function preview(att: ChatAttachment) {
|
||||
return render(
|
||||
<AttachmentPreview
|
||||
workspaceId="ws-1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AttachmentPreview dispatch", () => {
|
||||
it("kind=file → renders the AttachmentChip download button (existing fallback)", () => {
|
||||
preview({ uri: "workspace:/workspace/tmp/foo.zip", name: "foo.zip", mimeType: "application/zip" });
|
||||
// The chip's button title is `Download <name>`. Pre-fix this was
|
||||
// the only render path; now it's the kind=file fallback.
|
||||
expect(screen.getByTitle(/Download foo\.zip/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=image (mime) → renders the AttachmentImage path (loading placeholder until fetch resolves)", async () => {
|
||||
// never-resolving fetch → component sits in loading state. Pin
|
||||
// the loading placeholder shape.
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
preview({ uri: "workspace:/workspace/tmp/photo.png", name: "photo.png", mimeType: "image/png" });
|
||||
expect(await screen.findByLabelText(/Loading photo\.png/i)).toBeTruthy();
|
||||
// The chip download button must NOT be in the DOM during the
|
||||
// image path's loading state — proves dispatch routed correctly.
|
||||
expect(screen.queryByTitle(/Download photo\.png/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("kind=image (extension fallback when mime is empty) → image path", async () => {
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
preview({ uri: "workspace:/workspace/screenshot.jpg", name: "screenshot.jpg" /* no mime */ });
|
||||
expect(await screen.findByLabelText(/Loading screenshot\.jpg/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=image fetch fails (404) → falls back to AttachmentChip so the user can still download", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/tmp/missing.png", name: "missing.png", mimeType: "image/png" });
|
||||
// The fallback chip shows up on error.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.png/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=image fetch network error → falls back to chip", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download x\.png/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=image success → renders <img> + clicking opens the lightbox", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-png-bytes"], { type: "image/png" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/tmp/ok.png", name: "ok.png", mimeType: "image/png" });
|
||||
|
||||
// Image element shows up after the fetch resolves.
|
||||
const img = await screen.findByAltText(/ok\.png/);
|
||||
expect(img).toBeTruthy();
|
||||
expect((img as HTMLImageElement).src).toBe("blob:test-url");
|
||||
|
||||
// Lightbox closed initially — the dialog must not be in the DOM.
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
// Click the thumbnail button (the surrounding <button>) → lightbox opens.
|
||||
const button = screen.getByLabelText(/Open ok\.png preview/i);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Close preview/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=image lightbox closes on Esc keypress", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["b"], { type: "image/png" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
|
||||
await screen.findByAltText(/x\.png/);
|
||||
fireEvent.click(screen.getByLabelText(/Open x\.png preview/i));
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
|
||||
// Esc on document — lightbox listens there per design (not on
|
||||
// the modal element) so the user can press Esc anywhere.
|
||||
act(() => {
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=image lightbox closes on backdrop click but not on inner content click", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["b"], { type: "image/png" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
|
||||
await screen.findByAltText(/x\.png/);
|
||||
fireEvent.click(screen.getByLabelText(/Open x\.png preview/i));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
// Click on the inner content (the lightbox image) — must NOT close.
|
||||
const lightboxImg = dialog.querySelector("img");
|
||||
if (!lightboxImg) throw new Error("lightbox img missing");
|
||||
fireEvent.click(lightboxImg);
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
|
||||
// Click on the backdrop (the dialog itself) — closes.
|
||||
fireEvent.click(dialog);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PR-2: video / audio dispatch ───────────────────────────────
|
||||
|
||||
it("kind=video → renders <video controls> after fetch resolves", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-mp4"], { type: "video/mp4" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/clip.mp4", name: "clip.mp4", mimeType: "video/mp4" });
|
||||
// Loading placeholder first.
|
||||
expect(await screen.findByLabelText(/Loading clip\.mp4/i)).toBeTruthy();
|
||||
// After the blob resolves, a <video> element with controls=true
|
||||
// is in the DOM. Use a tag query — there's no built-in role for
|
||||
// <video>, but the element is unambiguous in the bubble.
|
||||
await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v).not.toBeNull();
|
||||
// controls attribute pinned — without it the user can't play.
|
||||
expect(v?.hasAttribute("controls")).toBe(true);
|
||||
// src is the blob URL we minted.
|
||||
expect((v as HTMLVideoElement).src).toBe("blob:test-url");
|
||||
});
|
||||
// Chip MUST NOT render — proves dispatch routed to video, not file.
|
||||
expect(screen.queryByTitle(/Download clip\.mp4/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("kind=video fetch fails → falls back to AttachmentChip", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/missing.mp4", name: "missing.mp4", mimeType: "video/mp4" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.mp4/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=video by extension fallback (no mime) → video path", async () => {
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
preview({ uri: "workspace:/workspace/recording.webm", name: "recording.webm" });
|
||||
expect(await screen.findByLabelText(/Loading recording\.webm/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=audio → renders <audio controls> with filename label", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-mp3"], { type: "audio/mpeg" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/song.mp3", name: "song.mp3", mimeType: "audio/mpeg" });
|
||||
await waitFor(() => {
|
||||
const a = document.querySelector("audio");
|
||||
expect(a).not.toBeNull();
|
||||
expect(a?.hasAttribute("controls")).toBe(true);
|
||||
expect((a as HTMLAudioElement).src).toBe("blob:test-url");
|
||||
});
|
||||
// Filename label pinned: helps the user know what they're hearing
|
||||
// BEFORE pressing play. Multiple matches — `<span>` text and the
|
||||
// `<audio>`'s fallback `{name}` text node — so getAllByText.
|
||||
expect(screen.getAllByText("song.mp3").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("kind=audio fetch fails → falls back to chip", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 403 });
|
||||
preview({ uri: "workspace:/workspace/locked.wav", name: "locked.wav", mimeType: "audio/wav" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download locked\.wav/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PR-3: PDF / text dispatch ─────────────────────────────────────
|
||||
|
||||
it("kind=pdf → renders the PDF preview chip (click opens lightbox)", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["%PDF-1.4..."], { type: "application/pdf" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/doc.pdf", name: "doc.pdf", mimeType: "application/pdf" });
|
||||
|
||||
// Loading placeholder first.
|
||||
expect(await screen.findByLabelText(/Loading doc\.pdf/i)).toBeTruthy();
|
||||
|
||||
// After fetch, preview chip with "PDF" tag rendered.
|
||||
await waitFor(() => {
|
||||
// The button title is "Preview doc.pdf"; alongside is a "PDF" tag.
|
||||
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click → lightbox opens with <embed> inside.
|
||||
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("kind=pdf fetch fails → falls back to chip", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/missing.pdf", name: "missing.pdf", mimeType: "application/pdf" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.pdf/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=text (text/plain) → renders inline <pre><code> preview", async () => {
|
||||
const body = "line1\nline2\nline3";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
body: null,
|
||||
text: async () => body,
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/log.txt", name: "log.txt", mimeType: "text/plain" });
|
||||
|
||||
// testing-library normalizes whitespace by default. The <pre>
|
||||
// contains the literal text node, so query the DOM directly.
|
||||
await waitFor(() => {
|
||||
const code = document.querySelector("pre code");
|
||||
expect(code).not.toBeNull();
|
||||
expect(code?.textContent).toBe("line1\nline2\nline3");
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=text long content → shows 'Show all N lines' button when >10 lines", async () => {
|
||||
// 25 lines, default preview shows 10. Button labels with full count.
|
||||
const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
body: null,
|
||||
text: async () => body,
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/big.txt", name: "big.txt", mimeType: "text/plain" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Show all 25 lines/i })).toBeTruthy();
|
||||
});
|
||||
// Pre-expand: only first 10 lines in <code>; line 11+ absent.
|
||||
let code = document.querySelector("pre code");
|
||||
expect(code?.textContent?.includes("line 10")).toBe(true);
|
||||
expect(code?.textContent?.includes("line 11")).toBe(false);
|
||||
|
||||
// After clicking expand, all 25 lines present.
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show all 25 lines/i }));
|
||||
await waitFor(() => {
|
||||
code = document.querySelector("pre code");
|
||||
expect(code?.textContent?.includes("line 25")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=text fetch fails → chip fallback", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/missing.json", name: "missing.json", mimeType: "application/json" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.json/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── universal-fallback regression ─────────────────────────────────
|
||||
|
||||
it("kind=file is the universal fallback for unknown MIME (regression: don't try to preview a zip)", () => {
|
||||
// Critical safety: agent could attach a misnamed file. Pre-fix
|
||||
// the chip path was unconditional; we want unknown MIME to
|
||||
// STILL go to the chip even though the extension matches an
|
||||
// image kind.
|
||||
preview({ uri: "workspace:/workspace/tmp/x.docx", name: "x.docx", mimeType: "application/vnd.zip-disguised-as-doc" });
|
||||
expect(screen.getByTitle(/Download x\.docx/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// preview-kind unit tests — exhaustive table of MIME / extension
|
||||
// combinations. The kind helper is a pure function; this is the
|
||||
// regression line for "what renders as what" across the entire chat
|
||||
// surface.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAttachmentPreviewKind } from "../preview-kind";
|
||||
|
||||
describe("getAttachmentPreviewKind", () => {
|
||||
describe("strict MIME match", () => {
|
||||
const cases: Array<[string, ReturnType<typeof getAttachmentPreviewKind>]> = [
|
||||
// images
|
||||
["image/png", "image"],
|
||||
["image/jpeg", "image"],
|
||||
["image/gif", "image"],
|
||||
["image/webp", "image"],
|
||||
["image/svg+xml", "image"],
|
||||
["image/avif", "image"],
|
||||
["IMAGE/PNG", "image"], // case-insensitive
|
||||
[" image/png ", "image"], // trim
|
||||
// video
|
||||
["video/mp4", "video"],
|
||||
["video/webm", "video"],
|
||||
["video/quicktime", "video"],
|
||||
// audio
|
||||
["audio/mpeg", "audio"],
|
||||
["audio/wav", "audio"],
|
||||
["audio/ogg", "audio"],
|
||||
// pdf
|
||||
["application/pdf", "pdf"],
|
||||
// text family
|
||||
["text/plain", "text"],
|
||||
["text/markdown", "text"],
|
||||
["text/html", "text"],
|
||||
["text/css", "text"],
|
||||
["text/javascript", "text"],
|
||||
["text/csv", "text"],
|
||||
["application/json", "text"],
|
||||
["application/yaml", "text"],
|
||||
["application/x-yaml", "text"],
|
||||
["application/javascript", "text"],
|
||||
["application/typescript", "text"],
|
||||
// unknown / non-renderable → file
|
||||
["application/zip", "file"],
|
||||
["application/octet-stream", "file"],
|
||||
["application/x-tar", "file"],
|
||||
["application/vnd.ms-excel", "file"],
|
||||
["weird/unknown-thing", "file"],
|
||||
];
|
||||
for (const [mime, expected] of cases) {
|
||||
it(`mimeType=${JSON.stringify(mime)} → ${expected}`, () => {
|
||||
expect(getAttachmentPreviewKind(mime)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("extension fallback when MIME is missing or generic", () => {
|
||||
const cases: Array<[string | undefined, string | undefined, string | undefined, ReturnType<typeof getAttachmentPreviewKind>]> = [
|
||||
// [mime, uri, name, expected]
|
||||
[undefined, "workspace:/tmp/screenshot.png", "screenshot.png", "image"],
|
||||
["", "workspace:/tmp/photo.JPG", "photo.JPG", "image"],
|
||||
["application/octet-stream", "workspace:/tmp/clip.mp4", "clip.mp4", "video"],
|
||||
[undefined, "workspace:/foo/song.mp3", "song.mp3", "audio"],
|
||||
[undefined, "workspace:/docs/report.pdf", "report.pdf", "pdf"],
|
||||
[undefined, "workspace:/code/main.py", "main.py", "text"],
|
||||
[undefined, "workspace:/data/notes.md", "notes.md", "text"],
|
||||
// No extension → file
|
||||
[undefined, "workspace:/tmp/Dockerfile", "Dockerfile", "file"],
|
||||
// Trailing dot → file
|
||||
[undefined, "workspace:/tmp/weird.", "weird.", "file"],
|
||||
// URL with query string + fragment → strip before parsing
|
||||
[undefined, "https://example.com/foo.png?download=1#anchor", "", "image"],
|
||||
// Unknown extension → file
|
||||
[undefined, "workspace:/tmp/something.xyz", "something.xyz", "file"],
|
||||
// Empty
|
||||
[undefined, "", "", "file"],
|
||||
[undefined, undefined, undefined, "file"],
|
||||
];
|
||||
for (const [mime, uri, name, expected] of cases) {
|
||||
it(`mime=${mime ?? "<undef>"} uri=${uri} name=${name} → ${expected}`, () => {
|
||||
expect(getAttachmentPreviewKind(mime, uri, name)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("MIME wins over extension", () => {
|
||||
it("explicit mime=application/zip + extension=.png → file (don't render zip as image)", () => {
|
||||
// Critical safety: agent might attach a .png-named file that's
|
||||
// actually a zip. The strict-MIME branch wins and we render
|
||||
// the chip, not an <img> that 404s on broken bytes.
|
||||
expect(getAttachmentPreviewKind("application/zip", "x.png", "x.png")).toBe("file");
|
||||
});
|
||||
|
||||
it("explicit mime=text/plain + extension=.png → text", () => {
|
||||
expect(getAttachmentPreviewKind("text/plain", "log.png", "log.png")).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: hostile-reviewer cases", () => {
|
||||
it("does NOT misclassify image/svg+xml as text (svg is image even though it has XML)", () => {
|
||||
expect(getAttachmentPreviewKind("image/svg+xml")).toBe("image");
|
||||
});
|
||||
|
||||
it("application/octet-stream + extension=.docx → file (no renderer, don't try)", () => {
|
||||
expect(getAttachmentPreviewKind("application/octet-stream", "f.docx", "f.docx")).toBe("file");
|
||||
});
|
||||
|
||||
it("non-canonical MIME application/json works", () => {
|
||||
expect(getAttachmentPreviewKind("application/json")).toBe("text");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// preview-kind.ts — single source of truth for "what renderer should
|
||||
// this attachment use" (RFC #2991, PR-1).
|
||||
//
|
||||
// Per the RFC's Phase 2 design, MIME type is the dispatch axis. The
|
||||
// wire shape (ChatAttachment.mimeType) already carries it end-to-end
|
||||
// from the server's chat_files.go through agent_message_writer.go to
|
||||
// the canvas hydrater — we just need to map it to a render kind.
|
||||
//
|
||||
// Why a separate file from AttachmentPreview.tsx: the kind helper is
|
||||
// a pure function that's easier to unit-test in isolation than a
|
||||
// React component, and unit tests across MIME families are the
|
||||
// regression line for new types added later.
|
||||
|
||||
/** The render-kind taxonomy. Each kind has a dedicated component:
|
||||
*
|
||||
* image → AttachmentImage (inline thumbnail + click → lightbox)
|
||||
* video → AttachmentVideo (HTML5 <video controls>, native fullscreen)
|
||||
* audio → AttachmentAudio (HTML5 <audio controls>)
|
||||
* pdf → AttachmentPDF (browser-native <embed>, fullscreen modal)
|
||||
* text → AttachmentTextPreview (monospace, first N lines, expand)
|
||||
* file → AttachmentChip (existing fallback — generic file pill)
|
||||
*
|
||||
* NB: `text` includes JSON, YAML, source code, plain text — anything
|
||||
* that renders sensibly as preformatted ASCII without a specialized
|
||||
* viewer. PR-1 ships only `image` + `file`; PR-2 adds video/audio;
|
||||
* PR-3 adds pdf + text. All routed through this same dispatch table
|
||||
* so adding a new kind is a one-line registration. */
|
||||
export type AttachmentPreviewKind = "image" | "video" | "audio" | "pdf" | "text" | "file";
|
||||
|
||||
/** Maps a MIME type to the render kind. Falls back to "file" for
|
||||
* any MIME we don't have a renderer for (current behavior — the
|
||||
* attachment chip is the universal fallback).
|
||||
*
|
||||
* Filename-based fallback: when mimeType is missing or generic
|
||||
* (application/octet-stream), inspect the URI's extension. The
|
||||
* workspace-server's chat_files.go derives Content-Type from the
|
||||
* file extension, but agent-emitted attachments may not always
|
||||
* set mimeType, and the canvas should still preview a file named
|
||||
* `screenshot.png` even if the wire shape lacks the MIME.
|
||||
*
|
||||
* Strict MIME match always wins; extension fallback only applies
|
||||
* to empty / generic. Unknown extension → "file". */
|
||||
export function getAttachmentPreviewKind(
|
||||
mimeType: string | undefined,
|
||||
uri?: string,
|
||||
name?: string,
|
||||
): AttachmentPreviewKind {
|
||||
const mime = (mimeType ?? "").toLowerCase().trim();
|
||||
|
||||
// Strict MIME match (preferred — set by server's Content-Type
|
||||
// detection or by the agent's explicit mimeType field).
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (
|
||||
mime.startsWith("text/") ||
|
||||
mime === "application/json" ||
|
||||
mime === "application/yaml" ||
|
||||
mime === "application/x-yaml" ||
|
||||
mime === "application/javascript" ||
|
||||
mime === "application/typescript"
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
// Extension-based fallback — only when MIME is missing or
|
||||
// application/octet-stream (the server's "I don't know" default).
|
||||
// Skip when MIME is set to something specific we just don't have
|
||||
// a renderer for (e.g. application/zip → file is correct).
|
||||
const looksGeneric = mime === "" || mime === "application/octet-stream";
|
||||
if (looksGeneric) {
|
||||
const ext = extractExtension(uri, name);
|
||||
if (ext) {
|
||||
const kind = EXTENSION_KIND.get(ext);
|
||||
if (kind) return kind;
|
||||
}
|
||||
}
|
||||
|
||||
return "file";
|
||||
}
|
||||
|
||||
// Extension → kind table for the fallback branch. Keep this list
|
||||
// short and curated — every entry is a UX commitment to render
|
||||
// inline, and a wrong inference (e.g. .doc rendered as text) is
|
||||
// worse than the generic file chip.
|
||||
const EXTENSION_KIND: ReadonlyMap<string, AttachmentPreviewKind> = new Map([
|
||||
// Images
|
||||
["png", "image"],
|
||||
["jpg", "image"],
|
||||
["jpeg", "image"],
|
||||
["gif", "image"],
|
||||
["webp", "image"],
|
||||
["svg", "image"],
|
||||
["avif", "image"],
|
||||
["bmp", "image"],
|
||||
// Video
|
||||
["mp4", "video"],
|
||||
["webm", "video"],
|
||||
["mov", "video"],
|
||||
["mkv", "video"],
|
||||
// Audio
|
||||
["mp3", "audio"],
|
||||
["wav", "audio"],
|
||||
["ogg", "audio"],
|
||||
["m4a", "audio"],
|
||||
["flac", "audio"],
|
||||
// PDF
|
||||
["pdf", "pdf"],
|
||||
// Text-ish (rendered as preformatted ASCII)
|
||||
["txt", "text"],
|
||||
["md", "text"],
|
||||
["json", "text"],
|
||||
["yaml", "text"],
|
||||
["yml", "text"],
|
||||
["js", "text"],
|
||||
["ts", "text"],
|
||||
["tsx", "text"],
|
||||
["jsx", "text"],
|
||||
["py", "text"],
|
||||
["go", "text"],
|
||||
["rs", "text"],
|
||||
["java", "text"],
|
||||
["c", "text"],
|
||||
["cpp", "text"],
|
||||
["h", "text"],
|
||||
["hpp", "text"],
|
||||
["sh", "text"],
|
||||
["bash", "text"],
|
||||
["html", "text"],
|
||||
["css", "text"],
|
||||
["sql", "text"],
|
||||
["toml", "text"],
|
||||
["ini", "text"],
|
||||
["xml", "text"],
|
||||
["csv", "text"],
|
||||
["log", "text"],
|
||||
]);
|
||||
|
||||
/** Extracts the lowercased extension from a uri or name, without
|
||||
* the leading dot. Returns "" when no extension is present. */
|
||||
function extractExtension(uri: string | undefined, name: string | undefined): string {
|
||||
// Prefer name (always a leaf path); fall back to uri's last
|
||||
// segment. Strip query string + fragment so a URI like
|
||||
// "https://example.com/foo.png?download=1" still parses as png.
|
||||
const candidate = name || uri || "";
|
||||
if (!candidate) return "";
|
||||
let leaf = candidate.split(/[\\/]/).pop() || "";
|
||||
// Drop ?query and #fragment.
|
||||
leaf = leaf.split(/[?#]/)[0];
|
||||
const dot = leaf.lastIndexOf(".");
|
||||
if (dot < 0 || dot === leaf.length - 1) return "";
|
||||
return leaf.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// verifyConfig is the typed dependency bundle for verifyParity.
|
||||
@@ -121,7 +122,7 @@ func verifyParity(ctx context.Context, cfg verifyConfig, stdout *os.File) (*veri
|
||||
matched := true
|
||||
for _, c := range legacy {
|
||||
if pluginContents[c] == 0 {
|
||||
fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, truncate(c, 80))
|
||||
fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, textutil.TruncateBytes(c, 80))
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
@@ -192,9 +193,4 @@ func queryLegacyMemories(ctx context.Context, db *sql.DB, workspaceID string) ([
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
|
||||
|
||||
@@ -349,16 +349,8 @@ func TestVerifyParity_PickSampleError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Truncate ---
|
||||
|
||||
func TestVerifyTruncate(t *testing.T) {
|
||||
if got := truncate("short", 10); got != "short" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := truncate(strings.Repeat("a", 200), 10); !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("expected ellipsis: %q", got)
|
||||
}
|
||||
}
|
||||
// Truncate moved to internal/textutil — coverage in
|
||||
// internal/textutil/truncate_test.go (TestTruncateBytes_RuneBoundary).
|
||||
|
||||
// --- CLI: -verify mode ---
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestResolveBindHost pins the precedence: BIND_ADDR explicit > dev-mode
|
||||
// fail-open default of 127.0.0.1 > production-shape empty (all interfaces).
|
||||
//
|
||||
// Mutation-test invariant: removing the IsDevModeFailOpen() branch makes
|
||||
// "no_bindaddr_devmode_unset_admin" fail (returns "" instead of "127.0.0.1").
|
||||
// Removing the BIND_ADDR branch makes "explicit_bindaddr_*" cases fail.
|
||||
func TestResolveBindHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
bindAddr string
|
||||
adminToken string
|
||||
molEnv string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no_bindaddr_devmode_unset_admin",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "dev",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_devmode_unset_admin_full_word",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "development",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_admin_set_in_dev_env",
|
||||
bindAddr: "",
|
||||
adminToken: "secret",
|
||||
molEnv: "dev",
|
||||
want: "", // ADMIN_TOKEN flips IsDevModeFailOpen to false → all interfaces
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_production_env",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "production",
|
||||
want: "", // production is not a dev value → all interfaces
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_unset_env",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "",
|
||||
want: "", // unset MOLECULE_ENV → not dev → all interfaces
|
||||
},
|
||||
{
|
||||
name: "explicit_bindaddr_loopback_overrides_devmode",
|
||||
bindAddr: "127.0.0.1",
|
||||
adminToken: "",
|
||||
molEnv: "dev",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "explicit_bindaddr_wildcard_overrides_devmode_default",
|
||||
bindAddr: "0.0.0.0",
|
||||
adminToken: "",
|
||||
molEnv: "dev",
|
||||
want: "0.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "explicit_bindaddr_in_production",
|
||||
bindAddr: "10.0.5.7",
|
||||
adminToken: "secret",
|
||||
molEnv: "production",
|
||||
want: "10.0.5.7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("BIND_ADDR", tc.bindAddr)
|
||||
t.Setenv("ADMIN_TOKEN", tc.adminToken)
|
||||
t.Setenv("MOLECULE_ENV", tc.molEnv)
|
||||
got := resolveBindHost()
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveBindHost() = %q, want %q (BIND_ADDR=%q ADMIN_TOKEN=%q MOLECULE_ENV=%q)",
|
||||
got, tc.want, tc.bindAddr, tc.adminToken, tc.molEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/imagewatch"
|
||||
memwiring "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/wiring"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
|
||||
@@ -337,15 +338,23 @@ func main() {
|
||||
// Router
|
||||
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr, memBundle)
|
||||
|
||||
// HTTP server with graceful shutdown
|
||||
// HTTP server with graceful shutdown.
|
||||
//
|
||||
// Bind host: in dev-mode (no ADMIN_TOKEN, MOLECULE_ENV=dev|development)
|
||||
// the AdminAuth chain fails open by design; pairing that with a wildcard
|
||||
// bind would expose unauth /workspaces to any same-LAN peer. Default to
|
||||
// loopback when fail-open is active. Operators who need LAN exposure set
|
||||
// BIND_ADDR=0.0.0.0 explicitly. Production (ADMIN_TOKEN set) is unchanged.
|
||||
// See molecule-core#7.
|
||||
bindHost := resolveBindHost()
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
log.Printf("Platform starting on :%s", port)
|
||||
log.Printf("Platform starting on %s:%s (dev-mode-fail-open=%v)", bindHost, port, middleware.IsDevModeFailOpen())
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
@@ -380,6 +389,29 @@ func envOr(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// resolveBindHost picks the listener interface for the HTTP server.
|
||||
//
|
||||
// Precedence:
|
||||
// 1. BIND_ADDR — explicit operator override (any value, including "0.0.0.0").
|
||||
// 2. dev-mode fail-open active → "127.0.0.1" (loopback only).
|
||||
// 3. otherwise → "" (Go binds every interface; existing prod/self-host shape).
|
||||
//
|
||||
// Coupling the loopback default to middleware.IsDevModeFailOpen() means the
|
||||
// two safety levers — bind narrowness and auth strength — move together. A
|
||||
// production deploy (ADMIN_TOKEN set) keeps binding to all interfaces because
|
||||
// the auth chain is doing its job; a dev Mac (no ADMIN_TOKEN, MOLECULE_ENV=dev)
|
||||
// is reachable only via loopback because the auth chain is fail-open. See
|
||||
// molecule-core#7 for the original LAN exposure finding.
|
||||
func resolveBindHost() string {
|
||||
if v := os.Getenv("BIND_ADDR"); v != "" {
|
||||
return v
|
||||
}
|
||||
if middleware.IsDevModeFailOpen() {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findConfigsDir() string {
|
||||
candidates := []string{
|
||||
"workspace-configs-templates",
|
||||
|
||||
@@ -51,7 +51,7 @@ func Import(
|
||||
return result
|
||||
}
|
||||
|
||||
_ = broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", wsID, map[string]interface{}{
|
||||
_ = broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), wsID, map[string]interface{}{
|
||||
"name": b.Name,
|
||||
"tier": b.Tier,
|
||||
"source_bundle_id": b.ID,
|
||||
@@ -142,7 +142,7 @@ func markFailed(ctx context.Context, wsID string, broadcaster *events.Broadcaste
|
||||
db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3`,
|
||||
models.StatusFailed, msg, wsID)
|
||||
broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", wsID, map[string]interface{}{
|
||||
broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), wsID, map[string]interface{}{
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -304,14 +305,14 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
|
||||
"parts": []map[string]interface{}{{"kind": "text", "text": msg.Text}},
|
||||
},
|
||||
"metadata": map[string]interface{}{
|
||||
"source": ch.ChannelType,
|
||||
"channel_id": ch.ID,
|
||||
"chat_id": msg.ChatID,
|
||||
"user_id": msg.UserID,
|
||||
"username": msg.Username,
|
||||
"message_id": msg.MessageID,
|
||||
"history": history,
|
||||
"extra": msg.Metadata,
|
||||
"source": ch.ChannelType,
|
||||
"channel_id": ch.ID,
|
||||
"chat_id": msg.ChatID,
|
||||
"user_id": msg.UserID,
|
||||
"username": msg.Username,
|
||||
"message_id": msg.MessageID,
|
||||
"history": history,
|
||||
"extra": msg.Metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -383,7 +384,7 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
|
||||
|
||||
// Broadcast event
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.RecordAndBroadcast(ctx, "CHANNEL_MESSAGE", ch.WorkspaceID, map[string]interface{}{
|
||||
m.broadcaster.RecordAndBroadcast(ctx, string(events.EventChannelMessage), ch.WorkspaceID, map[string]interface{}{
|
||||
"channel_id": ch.ID,
|
||||
"channel_type": ch.ChannelType,
|
||||
"username": msg.Username,
|
||||
@@ -427,7 +428,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
|
||||
}
|
||||
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.RecordAndBroadcast(ctx, "CHANNEL_MESSAGE", ch.WorkspaceID, map[string]interface{}{
|
||||
m.broadcaster.RecordAndBroadcast(ctx, string(events.EventChannelMessage), ch.WorkspaceID, map[string]interface{}{
|
||||
"channel_id": ch.ID,
|
||||
"channel_type": ch.ChannelType,
|
||||
"direction": "outbound",
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// proxyDispatchBuildError is a sentinel wrapper for failures inside
|
||||
// http.NewRequestWithContext. handleA2ADispatchError unwraps it to emit the
|
||||
// "failed to create proxy request" 500 instead of the standard 502/503 paths.
|
||||
@@ -90,10 +92,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
Response: gin.H{
|
||||
"error": "workspace agent busy — adapter handles retry (native_session)",
|
||||
"busy": true,
|
||||
"retry_after": busyRetryAfterSeconds,
|
||||
"native_session": true,
|
||||
"error": "workspace agent busy — adapter handles retry (native_session)",
|
||||
"busy": true,
|
||||
"retry_after": busyRetryAfterSeconds,
|
||||
"native_session": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -149,7 +151,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// Provisioner selection (mutually exclusive in production):
|
||||
// - h.provisioner != nil → local Docker deployment; IsRunning does docker inspect.
|
||||
// - h.cpProv != nil → SaaS / EC2 deployment; IsRunning calls CP's
|
||||
// /cp/workspaces/:id/status to read the EC2 state.
|
||||
// /cp/workspaces/:id/status to read the EC2 state.
|
||||
//
|
||||
// Pre-fix this function ONLY consulted h.provisioner — for SaaS tenants
|
||||
// (h.provisioner=nil, h.cpProv=set) it short-circuited to false on every
|
||||
@@ -191,7 +193,7 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
log.Printf("ProxyA2A: failed to mark workspace %s offline: %v", workspaceID, err)
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_OFFLINE", workspaceID, map[string]interface{}{})
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
go h.RestartByID(workspaceID)
|
||||
return true
|
||||
}
|
||||
@@ -272,7 +274,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
}(ctx)
|
||||
|
||||
if callerID == "" && statusCode < 400 {
|
||||
h.broadcaster.BroadcastOnly(workspaceID, "A2A_RESPONSE", map[string]interface{}{
|
||||
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
|
||||
"response_body": json.RawMessage(respBody),
|
||||
"method": a2aMethod,
|
||||
"duration_ms": durationMs,
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// extractIdempotencyKey pulls params.message.messageId out of an A2A JSON-RPC
|
||||
@@ -419,7 +421,7 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
|
||||
AND method = 'delegate_result'
|
||||
AND target_id = $4
|
||||
AND response_body->>'delegation_id' = $5
|
||||
`, "Delegation completed ("+truncate(responseText, 80)+")", string(respJSON),
|
||||
`, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON),
|
||||
sourceID, targetID, delegationID)
|
||||
if err != nil {
|
||||
log.Printf("A2AQueue drain stitch: update failed for delegation %s: %v", delegationID, err)
|
||||
@@ -435,10 +437,10 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
|
||||
// "⏸ queued" line to "✓ completed" in real time. Without this the
|
||||
// transition only surfaces after the user reloads or polls activity.
|
||||
if h.broadcaster != nil {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"target_id": targetID,
|
||||
"response_preview": truncate(responseText, 200),
|
||||
"response_preview": textutil.TruncateBytes(responseText, 200),
|
||||
"via": "queue_drain",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
|
||||
func (h *ActivityHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
activityType := c.Query("type")
|
||||
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
|
||||
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
|
||||
peerID := c.Query("peer_id") // optional UUID — restrict to rows where this peer is sender OR target
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
sinceSecsStr := c.Query("since_secs")
|
||||
@@ -580,7 +580,45 @@ func (h *ActivityHandler) Report(c *gin.Context) {
|
||||
// LogActivity inserts an activity log and optionally broadcasts via WebSocket.
|
||||
// Takes events.EventEmitter (#1814) so callers passing a stub broadcaster
|
||||
// in tests no longer need to construct the full *events.Broadcaster.
|
||||
//
|
||||
// Errors are logged and swallowed — this is the fire-and-forget contract
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Printf("LogActivity insert error: %v", err)
|
||||
return
|
||||
}
|
||||
hook()
|
||||
}
|
||||
|
||||
// LogActivityTx inserts the activity row inside the caller-provided tx
|
||||
// and returns a commitHook that fires the post-commit ACTIVITY_LOGGED
|
||||
// broadcast. Caller MUST invoke commitHook AFTER tx.Commit() — firing
|
||||
// it before commit can leak a WebSocket event for a row that ends up
|
||||
// rolled back, which the canvas's optimistic UI then shows then loses.
|
||||
//
|
||||
// Returns an error if the INSERT fails — caller should Rollback. Caller
|
||||
// is also responsible for tx.BeginTx + tx.Commit/Rollback. Used by
|
||||
// chat_files uploadPollMode so PutBatchTx + N activity rows commit
|
||||
// atomically; if any activity row fails, the pending_uploads rows roll
|
||||
// back too and the client retries the entire multipart upload cleanly.
|
||||
func LogActivityTx(ctx context.Context, tx *sql.Tx, broadcaster events.EventEmitter, params ActivityParams) (commitHook func(), err error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("LogActivityTx: tx is nil")
|
||||
}
|
||||
return logActivityExec(ctx, tx, broadcaster, params)
|
||||
}
|
||||
|
||||
// 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).
|
||||
type activityExecutor interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster events.EventEmitter, params ActivityParams) (commitHook func(), err error) {
|
||||
reqJSON, reqErr := json.Marshal(params.RequestBody)
|
||||
if reqErr != nil {
|
||||
log.Printf("LogActivity: failed to marshal request_body for %s: %v", params.WorkspaceID, reqErr)
|
||||
@@ -606,20 +644,21 @@ func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params Ac
|
||||
traceStr = &s
|
||||
}
|
||||
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, tool_trace, duration_ms, status, error_detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb, $10, $11, $12)
|
||||
`, params.WorkspaceID, params.ActivityType, params.SourceID, params.TargetID,
|
||||
params.Method, params.Summary, reqStr, respStr, traceStr,
|
||||
params.DurationMs, params.Status, params.ErrorDetail)
|
||||
if err != nil {
|
||||
log.Printf("LogActivity insert error: %v", err)
|
||||
return
|
||||
params.DurationMs, params.Status, params.ErrorDetail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Broadcast ACTIVITY_LOGGED event
|
||||
// Build the broadcast payload up-front so the post-commit hook is a
|
||||
// pure in-memory call — no JSON marshaling between commit and emit
|
||||
// where a panic would leak the row without an event.
|
||||
var payload map[string]interface{}
|
||||
if broadcaster != nil {
|
||||
payload := map[string]interface{}{
|
||||
payload = map[string]interface{}{
|
||||
"activity_type": params.ActivityType,
|
||||
"method": params.Method,
|
||||
"summary": params.Summary,
|
||||
@@ -650,8 +689,13 @@ func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params Ac
|
||||
if respStr != nil {
|
||||
payload["response_body"] = json.RawMessage(respJSON)
|
||||
}
|
||||
broadcaster.BroadcastOnly(params.WorkspaceID, "ACTIVITY_LOGGED", payload)
|
||||
}
|
||||
|
||||
return func() {
|
||||
if broadcaster != nil {
|
||||
broadcaster.BroadcastOnly(params.WorkspaceID, string(events.EventActivityLogged), payload)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ActivityParams struct {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -909,6 +910,114 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
|
||||
// contract: LogActivityTx returns a commitHook that the caller MUST
|
||||
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
|
||||
// LogActivityTx itself. Firing inside would leak a websocket event
|
||||
// for a row that the caller may roll back, painting a ghost message
|
||||
// into the canvas's optimistic UI that disappears on the next refresh.
|
||||
func TestLogActivityTx_DefersBroadcastUntilCommitHook(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
tx, err := db.DB.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
method := "chat_upload_receive"
|
||||
hook, err := LogActivityTx(context.Background(), tx, cb, ActivityParams{
|
||||
WorkspaceID: "ws-123",
|
||||
ActivityType: "a2a_receive",
|
||||
Method: &method,
|
||||
Status: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LogActivityTx: %v", err)
|
||||
}
|
||||
if len(cb.calls) != 0 {
|
||||
t.Errorf("broadcast leaked before commitHook: got %d calls", len(cb.calls))
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
hook()
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("commitHook must broadcast exactly once, got %d", len(cb.calls))
|
||||
}
|
||||
if cb.calls[0].eventType != "ACTIVITY_LOGGED" {
|
||||
t.Errorf("event type = %q, want ACTIVITY_LOGGED", cb.calls[0].eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_InsertError_NoHook_NoBroadcast — when the INSERT
|
||||
// fails inside the Tx, LogActivityTx returns an error and a nil
|
||||
// commitHook. The caller is expected to Rollback; no broadcast can
|
||||
// possibly fire because the hook never exists.
|
||||
func TestLogActivityTx_InsertError_NoHook_NoBroadcast(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(errors.New("constraint violation simulated"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
tx, err := db.DB.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
method := "chat_upload_receive"
|
||||
hook, err := LogActivityTx(context.Background(), tx, cb, ActivityParams{
|
||||
WorkspaceID: "ws-123",
|
||||
ActivityType: "a2a_receive",
|
||||
Method: &method,
|
||||
Status: "ok",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on INSERT failure, got nil")
|
||||
}
|
||||
if hook != nil {
|
||||
t.Errorf("commitHook must be nil on insert error, got non-nil hook")
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if len(cb.calls) != 0 {
|
||||
t.Errorf("broadcast must NOT fire on insert error, got %d calls", len(cb.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_NilTx_Errors — passing a nil tx is caller misuse.
|
||||
// Return an error rather than panicking on the nil receiver inside
|
||||
// ExecContext (which would crash the request goroutine and surface as
|
||||
// a 500 with no log line tying it to the bad call site).
|
||||
func TestLogActivityTx_NilTx_Errors(t *testing.T) {
|
||||
cb := &recordingBroadcaster{}
|
||||
hook, err := LogActivityTx(context.Background(), nil, cb, ActivityParams{
|
||||
WorkspaceID: "ws-123",
|
||||
ActivityType: "a2a_receive",
|
||||
Status: "ok",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("nil tx must error, got nil")
|
||||
}
|
||||
if hook != nil {
|
||||
t.Errorf("commitHook must be nil when tx is nil, got non-nil hook")
|
||||
}
|
||||
if len(cb.calls) != 0 {
|
||||
t.Errorf("broadcast must NOT fire on nil-tx error, got %d", len(cb.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogActivity_Broadcast_IncludesResponseBody(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
@@ -56,10 +56,17 @@ type RefreshResult struct {
|
||||
Recreated []string `json:"recreated"`
|
||||
}
|
||||
|
||||
// TemplateImageRef returns the canonical GHCR ref for a runtime's template
|
||||
// image. Single source of truth shared with imagewatch.
|
||||
// TemplateImageRef returns the canonical image ref for a runtime's template,
|
||||
// using the configured registry (provisioner.RegistryPrefix()) and the
|
||||
// moving `:latest` tag. Single source of truth shared with imagewatch.
|
||||
//
|
||||
// Defaults to ghcr.io/molecule-ai/workspace-template-<runtime>:latest
|
||||
// (upstream OSS). When MOLECULE_IMAGE_REGISTRY is set in the environment
|
||||
// (typically the AWS ECR mirror in production), this returns the prefixed
|
||||
// equivalent so admin operations and image-watch checks hit the same
|
||||
// registry the provisioner pulls from.
|
||||
func TemplateImageRef(runtime string) string {
|
||||
return fmt.Sprintf("ghcr.io/molecule-ai/workspace-template-%s:latest", runtime)
|
||||
return fmt.Sprintf("%s/workspace-template-%s:latest", provisioner.RegistryPrefix(), runtime)
|
||||
}
|
||||
|
||||
// ghcrAuthHeader returns the base64-encoded JSON auth payload Docker's
|
||||
|
||||
@@ -69,7 +69,7 @@ func (h *AgentHandler) Assign(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_ASSIGNED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentAssigned), workspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": body.Model,
|
||||
})
|
||||
@@ -118,7 +118,7 @@ func (h *AgentHandler) Replace(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_REPLACED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentReplaced), workspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": body.Model,
|
||||
"old_model": oldModel,
|
||||
@@ -148,7 +148,7 @@ func (h *AgentHandler) Remove(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_REMOVED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentRemoved), workspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
})
|
||||
@@ -215,21 +215,21 @@ func (h *AgentHandler) Move(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Broadcast on both workspaces
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_MOVED", sourceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"target_workspace_id": body.TargetWorkspaceID,
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentMoved), sourceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"target_workspace_id": body.TargetWorkspaceID,
|
||||
})
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_MOVED", body.TargetWorkspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"source_workspace_id": sourceID,
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentMoved), body.TargetWorkspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"source_workspace_id": sourceID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"from_workspace": sourceID,
|
||||
"to_workspace": body.TargetWorkspaceID,
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"from_workspace": sourceID,
|
||||
"to_workspace": body.TargetWorkspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// ErrWorkspaceNotFound is returned by AgentMessageWriter.Send when the
|
||||
@@ -54,36 +54,6 @@ import (
|
||||
// timeout) surface as wrapped errors and should be treated as 503.
|
||||
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
|
||||
|
||||
// truncatePreviewRunes returns at most maxRunes runes of s, plus an ellipsis
|
||||
// when truncated. Operates on the rune (codepoint) boundary instead of
|
||||
// byte indices — the previous byte-slice version produced invalid UTF-8
|
||||
// when maxRunes landed mid-codepoint (CJK, emoji, accented characters
|
||||
// in agent-authored chat messages), and Postgres JSONB rejects invalid
|
||||
// UTF-8, dropping the activity_log INSERT silently. The persistence
|
||||
// failure log fires but the message vanishes from chat history — the
|
||||
// exact regression class the SSOT consolidation was built to prevent.
|
||||
//
|
||||
// maxRunes is in runes, not bytes — `truncatePreviewRunes("你好", 1)` returns
|
||||
// `"你…"`, not `"\xe4…"`. Set the cap on a UI-friendly basis (visible
|
||||
// character count, not stored byte count); 80 runes covers the
|
||||
// activity_logs.summary column comfortably.
|
||||
func truncatePreviewRunes(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
// Walk runes until we've consumed maxRunes; cut at that byte index.
|
||||
count := 0
|
||||
cut := len(s)
|
||||
for i := range s {
|
||||
if count == maxRunes {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s[:cut] + "…"
|
||||
}
|
||||
|
||||
// AgentMessageAttachment is one file attached to an agent → user
|
||||
// message. Identical to handlers.NotifyAttachment in field set; kept
|
||||
// distinct so the writer's API doesn't import a handler type with HTTP
|
||||
@@ -186,7 +156,7 @@ func (w *AgentMessageWriter) Send(
|
||||
respPayload["parts"] = fileParts
|
||||
}
|
||||
respJSON, _ := json.Marshal(respPayload)
|
||||
preview := truncatePreviewRunes(message, 80)
|
||||
preview := textutil.TruncateRunes(message, 80)
|
||||
if _, err := w.db.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, summary, response_body, status)
|
||||
VALUES ($1, 'a2a_receive', 'notify', $2, $3::jsonb, 'ok')
|
||||
|
||||
@@ -331,45 +331,11 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncatePreviewRunes_RuneBoundary pins the multi-byte-safe
|
||||
// truncation. The previous byte-slice version produced invalid UTF-8
|
||||
// when the cut landed mid-codepoint (CJK, emoji, accented), and
|
||||
// Postgres JSONB rejects invalid UTF-8 — INSERT fails, log.Printf
|
||||
// fires, message vanishes from chat history. Per memory
|
||||
// feedback_assert_exact_not_substring.md, pin the boundary cases
|
||||
// directly.
|
||||
func TestTruncatePreviewRunes_RuneBoundary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"under-max ASCII", "hi", 80, "hi"},
|
||||
{"under-max CJK", "你好", 80, "你好"},
|
||||
{"exactly-at-max", "abcde", 5, "abcde"},
|
||||
{"truncate ASCII", "abcdefghij", 5, "abcde…"},
|
||||
{"truncate CJK at rune boundary", "你好世界你好世界", 4, "你好世界…"},
|
||||
{"truncate emoji at rune boundary", "😀😀😀😀😀😀", 3, "😀😀😀…"},
|
||||
// The pre-fix bug shape: byte-slice on non-ASCII would have
|
||||
// mangled the codepoint here. With rune-boundary truncation
|
||||
// the result is well-formed UTF-8.
|
||||
{"non-zero with emoji prefix", "🚀abcdefghijk", 5, "🚀abcd…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := truncatePreviewRunes(c.in, c.max)
|
||||
if got != c.want {
|
||||
t.Errorf("truncatePreviewRunes(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
|
||||
}
|
||||
// Always-valid UTF-8 invariant. A byte-slice truncation
|
||||
// could leave partial codepoints; this version must not.
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("truncatePreviewRunes(%q, %d) returned invalid UTF-8: %q", c.in, c.max, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Helper-level truncate tests now live in
|
||||
// internal/textutil/truncate_test.go (TestTruncateRunes). The
|
||||
// integration-level coverage that exercises the agent_message_writer
|
||||
// path with non-ASCII content is TestAgentMessageWriter_Send_NonASCIIMessagePersists
|
||||
// below.
|
||||
|
||||
// TestAgentMessageWriter_Send_NonASCIIMessagePersists pins the end-to-end
|
||||
// path for non-ASCII messages — the original reno-stars regression
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_REQUESTED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalRequested), workspaceID, map[string]interface{}{
|
||||
"approval_id": approvalID,
|
||||
"action": body.Action,
|
||||
"reason": body.Reason,
|
||||
@@ -62,7 +62,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
|
||||
var parentID *string
|
||||
db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
|
||||
if parentID != nil {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_ESCALATED", *parentID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalEscalated), *parentID, map[string]interface{}{
|
||||
"approval_id": approvalID,
|
||||
"from_workspace_id": workspaceID,
|
||||
"action": body.Action,
|
||||
|
||||
@@ -656,8 +656,28 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 2: atomic batch insert. On failure no rows commit.
|
||||
fileIDs, err := h.pendingUploads.PutBatch(ctx, wsUUID, items)
|
||||
// Phase 2+3: PutBatch + N activity-row inserts run in ONE Tx so
|
||||
// either every pending_uploads row + every activity_logs row commits,
|
||||
// or none do. Per-file pre-validation already happened above so the
|
||||
// only failure modes inside the Tx are DB-side; either way Rollback
|
||||
// leaves the table state unchanged and the client retries the whole
|
||||
// multipart upload cleanly. Broadcasts are deferred until after
|
||||
// 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)
|
||||
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"})
|
||||
return
|
||||
}
|
||||
// Defer-rollback is safe even after a successful Commit — the second
|
||||
// Rollback is a no-op (database/sql tracks tx state).
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
fileIDs, err := h.pendingUploads.PutBatchTx(ctx, tx, wsUUID, items)
|
||||
if err != nil {
|
||||
if errors.Is(err, pendinguploads.ErrTooLarge) {
|
||||
// Belt + suspenders: pre-validation above already caught
|
||||
@@ -669,28 +689,20 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("chat_files uploadPollMode: storage.PutBatch failed for %s: %v",
|
||||
log.Printf("chat_files uploadPollMode: storage.PutBatchTx failed for %s: %v",
|
||||
workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not stage files"})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 3: write per-file activity rows and build the response. Activity
|
||||
// rows are written individually (not part of the same Tx as PutBatch)
|
||||
// because LogActivity is shared across many handlers and threading the
|
||||
// Tx through would be a bigger refactor. The trade-off: if an activity
|
||||
// write fails after the PutBatch commits, the pending_uploads rows
|
||||
// orphan until the 24h TTL — significantly better than the previous
|
||||
// "every multi-file upload could orphan" behavior, and the workspace's
|
||||
// fetcher handles soft-404 cleanly when activity rows reference a row
|
||||
// the platform later expired.
|
||||
out := make([]uploadedFile, 0, len(prepReady))
|
||||
broadcasts := make([]func(), 0, len(prepReady))
|
||||
for i, p := range prepReady {
|
||||
fileID := fileIDs[i]
|
||||
uri := fmt.Sprintf("platform-pending:%s/%s", workspaceID, fileID)
|
||||
summary := "chat_upload_receive: " + p.Sanitized
|
||||
method := "chat_upload_receive"
|
||||
LogActivity(ctx, h.broadcaster, ActivityParams{
|
||||
hook, err := LogActivityTx(ctx, tx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
ActivityType: "a2a_receive",
|
||||
TargetID: &workspaceID,
|
||||
@@ -705,10 +717,13 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
|
||||
},
|
||||
Status: "ok",
|
||||
})
|
||||
|
||||
log.Printf("chat_files uploadPollMode: staged %s/%s (file_id=%s size=%d mimetype=%q)",
|
||||
workspaceID, p.Sanitized, fileID, len(p.Content), p.Mimetype)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("chat_files uploadPollMode: activity insert failed for %s/%s: %v",
|
||||
workspaceID, p.Sanitized, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not log upload activity"})
|
||||
return
|
||||
}
|
||||
broadcasts = append(broadcasts, hook)
|
||||
out = append(out, uploadedFile{
|
||||
URI: uri,
|
||||
Name: p.Sanitized,
|
||||
@@ -717,6 +732,24 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("chat_files uploadPollMode: commit failed for %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not stage files"})
|
||||
return
|
||||
}
|
||||
|
||||
// Post-commit: fire deferred broadcasts and emit the staged log
|
||||
// lines now that the rows are durable. Broadcasts are pure in-memory
|
||||
// (no I/O); panicking here would NOT leak a row but would leak a
|
||||
// log line, so the order doesn't matter for correctness.
|
||||
for _, b := range broadcasts {
|
||||
b()
|
||||
}
|
||||
for i, p := range prepReady {
|
||||
log.Printf("chat_files uploadPollMode: staged %s/%s (file_id=%s size=%d mimetype=%q)",
|
||||
workspaceID, p.Sanitized, fileIDs[i], len(p.Content), p.Mimetype)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"files": out})
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,16 @@ func (s *inMemStorage) PutBatch(_ context.Context, ws uuid.UUID, items []pending
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// PutBatchTx mirrors PutBatch for the Tx-aware caller path. The tx
|
||||
// argument is not consulted — production atomicity (PutBatch INSERTs +
|
||||
// activity_logs INSERTs in the same Tx) is verified by the dedicated
|
||||
// integration test against real Postgres. This in-mem fake records the
|
||||
// puts immediately; tests that exercise the rollback path use
|
||||
// putErr/sqlmock to simulate the failure.
|
||||
func (s *inMemStorage) PutBatchTx(ctx context.Context, _ *sql.Tx, ws uuid.UUID, items []pendinguploads.PutItem) ([]uuid.UUID, error) {
|
||||
return s.PutBatch(ctx, ws, items)
|
||||
}
|
||||
|
||||
func (s *inMemStorage) Get(context.Context, uuid.UUID) (pendinguploads.Record, error) {
|
||||
return pendinguploads.Record{}, pendinguploads.ErrNotFound
|
||||
}
|
||||
@@ -138,11 +148,37 @@ func expectPollDeliveryModeMissing(mock sqlmock.Sqlmock, workspaceID string) {
|
||||
|
||||
// expectActivityInsert stubs the LogActivity INSERT so the poll branch's
|
||||
// per-file activity row write doesn't fail the sqlmock expectations.
|
||||
// In the post-#149 path this INSERT runs inside the BeginTx that wraps
|
||||
// PutBatchTx + N activity rows — pair it with expectUploadPollTxBegin
|
||||
// + expectUploadPollTxCommit (or Rollback) when the test exercises
|
||||
// uploadPollMode.
|
||||
func expectActivityInsert(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
|
||||
// expectUploadPollTxBegin marks the start of the BeginTx that
|
||||
// uploadPollMode opens around PutBatchTx + per-file LogActivityTx.
|
||||
// inMemStorage doesn't drive sqlmock for the pending_uploads INSERTs
|
||||
// (it's a process-local fake), so the only Tx-scoped DB calls
|
||||
// sqlmock sees are the activity_logs INSERTs.
|
||||
func expectUploadPollTxBegin(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectBegin()
|
||||
}
|
||||
|
||||
// expectUploadPollTxCommit pairs with expectUploadPollTxBegin on the
|
||||
// happy path — every activity row inserted, Tx committed.
|
||||
func expectUploadPollTxCommit(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectCommit()
|
||||
}
|
||||
|
||||
// expectUploadPollTxRollback pairs with expectUploadPollTxBegin on a
|
||||
// failure path — PutBatchTx error, activity insert error, or any other
|
||||
// abort that triggers the deferred tx.Rollback() in uploadPollMode.
|
||||
func expectUploadPollTxRollback(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectRollback()
|
||||
}
|
||||
|
||||
// expectActivityInsertWithTypeAndMethod is a strict variant that pins
|
||||
// the activity_type and method positional args. Used in the discriminator
|
||||
// regression test below — the workspace inbox poller filters
|
||||
@@ -198,7 +234,9 @@ func TestPollUpload_HappyPath_OneFile_StagesAndLogs(t *testing.T) {
|
||||
|
||||
wsID := "11111111-2222-3333-4444-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectActivityInsert(mock)
|
||||
expectUploadPollTxCommit(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
|
||||
@@ -254,9 +292,11 @@ func TestPollUpload_MultipleFiles_AllStagedAndLogged(t *testing.T) {
|
||||
|
||||
wsID := "11111111-aaaa-bbbb-cccc-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectActivityInsert(mock)
|
||||
expectActivityInsert(mock)
|
||||
expectActivityInsert(mock)
|
||||
expectUploadPollTxCommit(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
|
||||
@@ -425,6 +465,8 @@ func TestPollUpload_StorageError_500(t *testing.T) {
|
||||
|
||||
wsID := "88888888-2222-3333-4444-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectUploadPollTxRollback(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
store.putErr = errors.New("disk full")
|
||||
@@ -446,6 +488,8 @@ func TestPollUpload_StorageTooLarge_413(t *testing.T) {
|
||||
|
||||
wsID := "99999999-2222-3333-4444-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectUploadPollTxRollback(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
store.putErr = pendinguploads.ErrTooLarge
|
||||
@@ -569,7 +613,9 @@ func TestPollUpload_SanitizesFilenameInResponse(t *testing.T) {
|
||||
|
||||
wsID := "bbbbbbbb-2222-3333-4444-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectActivityInsert(mock)
|
||||
expectUploadPollTxCommit(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
|
||||
@@ -650,6 +696,8 @@ func TestPollUpload_AtomicRollbackOnPutBatchError(t *testing.T) {
|
||||
|
||||
wsID := "bbbbbbbb-3333-3333-4444-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectUploadPollTxRollback(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
store.putErr = errors.New("db down mid-batch")
|
||||
@@ -672,6 +720,58 @@ func TestPollUpload_AtomicRollbackOnPutBatchError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollUpload_AtomicRollbackOnActivityInsertFailure pins the #149
|
||||
// guarantee: if an activity_logs INSERT fails mid-loop (after some
|
||||
// rows have already been INSERTed in the same Tx), uploadPollMode
|
||||
// MUST Rollback so neither the pending_uploads nor the activity rows
|
||||
// commit. Pre-#149 the activity rows were written one-by-one outside
|
||||
// any Tx; a mid-loop failure left orphan pending_uploads rows the
|
||||
// 24h TTL would later sweep, but the user never saw the file in the
|
||||
// canvas. Post-#149 the contract is all-or-nothing.
|
||||
//
|
||||
// What this pins: the second activity insert errors → Tx rolls back
|
||||
// → response is 500 → no Commit. Pin via the sqlmock rollback
|
||||
// expectation; the inMemStorage will report puts=N (it doesn't model
|
||||
// Tx state), but at the SQL layer no rows committed.
|
||||
func TestPollUpload_AtomicRollbackOnActivityInsertFailure(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
wsID := "cccccccc-3333-3333-4444-555555555555"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
// File 1 inserts cleanly. File 2's INSERT fails. uploadPollMode
|
||||
// must NOT call Commit and the deferred tx.Rollback() runs.
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnError(errors.New("constraint violation simulated"))
|
||||
expectUploadPollTxRollback(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
|
||||
WithPendingUploads(store, nil)
|
||||
|
||||
body, ct := pollUploadFixture(t, map[string][]byte{
|
||||
"a.txt": []byte("aaa"),
|
||||
"b.txt": []byte("bbb"),
|
||||
"c.txt": []byte("ccc"),
|
||||
})
|
||||
c, w := makeUploadRequest(t, wsID, body, ct)
|
||||
h.Upload(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d body=%s, want 500 on activity-insert mid-loop failure",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
// This is the load-bearing assertion: ExpectationsWereMet only
|
||||
// passes if Rollback was called and Commit was NOT — the SQL-
|
||||
// level proof of the all-or-nothing contract.
|
||||
t.Errorf("Tx must rollback (and NOT commit) on activity-insert failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollUpload_MimetypeWithCRLFInjectionStripped pins the safeMimetype
|
||||
// hardening: a multipart-supplied Content-Type header with CR/LF is
|
||||
// rewritten to application/octet-stream so the eventual /content
|
||||
@@ -731,7 +831,9 @@ func TestPollUpload_ActivityRowDiscriminator(t *testing.T) {
|
||||
|
||||
wsID := "abc12345-6789-4abc-8def-000000000999"
|
||||
expectPollDeliveryMode(mock, wsID, "poll")
|
||||
expectUploadPollTxBegin(mock)
|
||||
expectActivityInsertWithTypeAndMethod(mock, wsID, "a2a_receive", "chat_upload_receive")
|
||||
expectUploadPollTxCommit(mock)
|
||||
|
||||
store := newInMemStorage()
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
// chat_history.go — HTTP-shape adapter over messagestore.MessageStore
|
||||
// (RFC #2945 PR-D).
|
||||
//
|
||||
// Pre-PR-D, this file owned the activity_logs query AND the parser
|
||||
// AND the HTTP plumbing. PR-D extracts the storage + parser into
|
||||
// internal/messagestore/ so OSS operators can plug in alternative
|
||||
// backends (S3-tiered, vector store, in-memory). The handler is now
|
||||
// a thin adapter: parse query params → call store → emit JSON.
|
||||
//
|
||||
// Endpoint: GET /workspaces/:id/chat-history?limit=N&before_ts=T
|
||||
// Auth: same wsAuth chain as /workspaces/:id/activity (tenant
|
||||
// ADMIN_TOKEN + X-Molecule-Org-Id header). No new trust boundary.
|
||||
//
|
||||
// Behavioral parity with canvas TS is enforced at the messagestore
|
||||
// layer (internal/messagestore/postgres_store_test.go); this file's
|
||||
// tests cover the HTTP-shape concerns only.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/messagestore"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ChatHistoryResponse is the wire shape for GET /chat-history.
|
||||
type ChatHistoryResponse struct {
|
||||
Messages []messagestore.ChatMessage `json:"messages"`
|
||||
ReachedEnd bool `json:"reached_end"`
|
||||
}
|
||||
|
||||
// ChatHistoryHandler exposes the typed chat-history endpoint over a
|
||||
// MessageStore. The store is injected so OSS operators can swap the
|
||||
// backend without forking the handler.
|
||||
type ChatHistoryHandler struct {
|
||||
store messagestore.MessageStore
|
||||
}
|
||||
|
||||
// NewChatHistoryHandler wires a MessageStore (typically
|
||||
// messagestore.NewPostgresMessageStore at production startup).
|
||||
//
|
||||
// Tests inject fakes (see internal/handlers/chat_history_test.go).
|
||||
// Constructor takes the interface, not a concrete type, so the
|
||||
// platform-default vs OSS-alternative decision happens at wiring
|
||||
// time in router.go.
|
||||
func NewChatHistoryHandler(store messagestore.MessageStore) *ChatHistoryHandler {
|
||||
return &ChatHistoryHandler{store: store}
|
||||
}
|
||||
|
||||
// List handles GET /workspaces/:id/chat-history?limit=N&before_ts=T.
|
||||
//
|
||||
// Query parameters mirror /activity for caller convenience:
|
||||
//
|
||||
// - limit (default 100, max 1000) — page size
|
||||
// - before_ts (RFC3339, optional) — cursor for paginating backward
|
||||
//
|
||||
// Validates inputs at the trust boundary; the store sees only
|
||||
// well-formed ListOptions.
|
||||
func (h *ChatHistoryHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace id must be a UUID"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
opts := messagestore.ListOptions{Limit: limit}
|
||||
if v := c.Query("before_ts"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "before_ts must be an RFC3339 timestamp (e.g. 2026-05-01T00:00:00Z)",
|
||||
})
|
||||
return
|
||||
}
|
||||
opts.BeforeTS = t
|
||||
opts.HasBefore = true
|
||||
}
|
||||
|
||||
messages, reachedEnd, err := h.store.List(c.Request.Context(), workspaceID, opts)
|
||||
if err != nil {
|
||||
// Errors here are infra (DB unreachable, store impl failure).
|
||||
// Surface as 502 so the canvas can retry vs. treating as
|
||||
// "no rows."
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "chat history unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Defensive: if the store returns nil messages slice (any impl
|
||||
// might), emit empty array rather than `null` so canvas's JSON
|
||||
// parser doesn't have to handle two empty representations.
|
||||
if messages == nil {
|
||||
messages = []messagestore.ChatMessage{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ChatHistoryResponse{
|
||||
Messages: messages,
|
||||
ReachedEnd: reachedEnd,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package handlers
|
||||
|
||||
// chat_history_test.go — handler-level tests against a fake
|
||||
// MessageStore. The parser-level parity tests against the canvas TS
|
||||
// fixtures live in internal/messagestore/postgres_store_test.go;
|
||||
// this file covers the HTTP-shape concerns (param validation,
|
||||
// pagination passthrough, error mapping) without touching a DB.
|
||||
//
|
||||
// Why the split: PR-D extracted storage to messagestore.MessageStore.
|
||||
// The handler is now a thin adapter — its tests should exercise the
|
||||
// adapter (ParseQuery → store.List → emitJSON), not the parser. A
|
||||
// future MessageStore impl (S3, vector store) shares the same
|
||||
// handler; testing the handler against the interface keeps the
|
||||
// adapter test independent of any specific impl.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/messagestore"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const testWorkspaceID = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// fakeStore is a stub MessageStore for handler-level tests. Every
|
||||
// real store impl (Postgres, S3, vector) shares the handler — so a
|
||||
// fake that records inputs + returns scripted outputs is the right
|
||||
// granularity for HTTP-shape coverage.
|
||||
type fakeStore struct {
|
||||
// LastWorkspaceID + LastOpts capture the call shape so the test
|
||||
// can assert the handler passed the right args to the store.
|
||||
LastWorkspaceID string
|
||||
LastOpts messagestore.ListOptions
|
||||
|
||||
// Returns — set per test.
|
||||
ReturnMessages []messagestore.ChatMessage
|
||||
ReturnReachedEnd bool
|
||||
ReturnErr error
|
||||
|
||||
// Panic — if non-empty, List panics with this string. Used by
|
||||
// the resilience test to confirm the handler returns 502 on
|
||||
// store-impl failures rather than crashing the goroutine.
|
||||
PanicWith string
|
||||
}
|
||||
|
||||
func (s *fakeStore) List(ctx context.Context, workspaceID string, opts messagestore.ListOptions) ([]messagestore.ChatMessage, bool, error) {
|
||||
if s.PanicWith != "" {
|
||||
panic(s.PanicWith)
|
||||
}
|
||||
s.LastWorkspaceID = workspaceID
|
||||
s.LastOpts = opts
|
||||
return s.ReturnMessages, s.ReturnReachedEnd, s.ReturnErr
|
||||
}
|
||||
|
||||
// Compile-time assertion that fakeStore satisfies the interface.
|
||||
// Catches drift if the interface changes and the fake stops being a
|
||||
// drop-in for tests.
|
||||
var _ messagestore.MessageStore = (*fakeStore)(nil)
|
||||
|
||||
func newRouter(store messagestore.MessageStore) *gin.Engine {
|
||||
r := gin.New()
|
||||
h := NewChatHistoryHandler(store)
|
||||
r.GET("/workspaces/:id/chat-history", h.List)
|
||||
return r
|
||||
}
|
||||
|
||||
func doChatHistoryRequest(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Param validation
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistoryHandler_RejectsNonUUIDWorkspaceID(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
r := newRouter(store)
|
||||
|
||||
w := doChatHistoryRequest(t, r, "/workspaces/not-a-uuid/chat-history")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for non-UUID, got %d", w.Code)
|
||||
}
|
||||
if store.LastWorkspaceID != "" {
|
||||
t.Errorf("non-UUID reached the store: %q", store.LastWorkspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistoryHandler_RejectsMalformedBeforeTS(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
r := newRouter(store)
|
||||
|
||||
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?before_ts=not-a-timestamp")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for malformed before_ts, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "RFC3339") {
|
||||
t.Errorf("error message should mention RFC3339; got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistoryHandler_DefaultsLimitTo100(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
r := newRouter(store)
|
||||
|
||||
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
|
||||
if store.LastOpts.Limit != 100 {
|
||||
t.Errorf("default limit=%d want 100", store.LastOpts.Limit)
|
||||
}
|
||||
if store.LastOpts.HasBefore {
|
||||
t.Errorf("HasBefore should be false when no cursor passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistoryHandler_ClampsLimitToMax1000(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
r := newRouter(store)
|
||||
|
||||
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?limit=99999")
|
||||
if store.LastOpts.Limit != 1000 {
|
||||
t.Errorf("limit not clamped: got %d, want 1000", store.LastOpts.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistoryHandler_IgnoresInvalidLimit(t *testing.T) {
|
||||
// Negative or zero limits should fall back to default rather
|
||||
// than reach the store (which rejects them as a programming bug).
|
||||
store := &fakeStore{}
|
||||
r := newRouter(store)
|
||||
|
||||
for _, bad := range []string{"-1", "0", "abc"} {
|
||||
store.LastOpts = messagestore.ListOptions{}
|
||||
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?limit="+bad)
|
||||
if store.LastOpts.Limit != 100 {
|
||||
t.Errorf("limit=%q yielded %d, want default 100", bad, store.LastOpts.Limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pagination passthrough
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistoryHandler_BeforeTSPassedToStore(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
r := newRouter(store)
|
||||
|
||||
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?before_ts=2026-04-25T18:00:00Z&limit=25")
|
||||
|
||||
if !store.LastOpts.HasBefore {
|
||||
t.Errorf("HasBefore=false but query passed before_ts")
|
||||
}
|
||||
got := store.LastOpts.BeforeTS.UTC().Format("2006-01-02T15:04:05Z")
|
||||
if got != "2026-04-25T18:00:00Z" {
|
||||
t.Errorf("BeforeTS=%q want 2026-04-25T18:00:00Z", got)
|
||||
}
|
||||
if store.LastOpts.Limit != 25 {
|
||||
t.Errorf("limit=%d want 25", store.LastOpts.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Response shape
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistoryHandler_EmptyResultIsArrayNotNull(t *testing.T) {
|
||||
// nil messages slice from the store must serialize as `[]`,
|
||||
// not `null` — canvas's JSON parser has one path.
|
||||
store := &fakeStore{ReturnMessages: nil, ReturnReachedEnd: true}
|
||||
r := newRouter(store)
|
||||
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
var resp ChatHistoryResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("body not JSON: %v", err)
|
||||
}
|
||||
// json.Unmarshal of `null` into a []slice yields a nil — assert
|
||||
// the JSON literally contains "[]" so a future change that
|
||||
// forgets the nil-coercion would fail loudly.
|
||||
if !strings.Contains(w.Body.String(), `"messages":[]`) {
|
||||
t.Errorf("body should contain `\"messages\":[]`; got %s", w.Body.String())
|
||||
}
|
||||
if !resp.ReachedEnd {
|
||||
t.Errorf("reached_end not propagated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistoryHandler_NonEmptyResponsePreservesShape(t *testing.T) {
|
||||
size := int64(4096)
|
||||
store := &fakeStore{
|
||||
ReturnMessages: []messagestore.ChatMessage{
|
||||
{
|
||||
ID: "msg-1",
|
||||
Role: "user",
|
||||
Content: "hi",
|
||||
Timestamp: "2026-04-25T18:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: "msg-2",
|
||||
Role: "agent",
|
||||
Content: "hello back",
|
||||
Attachments: []messagestore.ChatAttachment{
|
||||
{Name: "img.png", URI: "workspace:/img.png", MimeType: "image/png", Size: &size},
|
||||
},
|
||||
Timestamp: "2026-04-25T18:00:01Z",
|
||||
},
|
||||
},
|
||||
ReturnReachedEnd: false,
|
||||
}
|
||||
r := newRouter(store)
|
||||
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp ChatHistoryResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("body not JSON: %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("messages=%d want 2", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[1].Attachments[0].Size == nil || *resp.Messages[1].Attachments[0].Size != 4096 {
|
||||
t.Errorf("size pointer flattened in JSON round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Error mapping — store errors become 502, not 500/panic
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistoryHandler_StoreErrorReturns502(t *testing.T) {
|
||||
store := &fakeStore{ReturnErr: errors.New("simulated DB unreachable")}
|
||||
r := newRouter(store)
|
||||
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Errorf("expected 502 on store error, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "unavailable") {
|
||||
t.Errorf("response body should communicate unavailability; got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Interface conformance — the platform-default Postgres impl is the
|
||||
// only impl in tree today, but the assertion catches future drift if
|
||||
// the interface evolves and the impl falls behind.
|
||||
// =====================================================================
|
||||
|
||||
func TestMessageStoreInterface_PostgresImplSatisfies(t *testing.T) {
|
||||
// Compile-time assertion lives in messagestore/postgres_store.go
|
||||
// (`var _ MessageStore = (*PostgresMessageStore)(nil)`). This
|
||||
// runtime test exists only to keep the conformance visible in
|
||||
// the handler test file — a reader of chat_history_test.go
|
||||
// shouldn't have to traverse to the messagestore package to see
|
||||
// what the handler is paired with.
|
||||
var s messagestore.MessageStore = messagestore.NewPostgresMessageStore(nil)
|
||||
_ = s
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -164,10 +165,10 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
|
||||
go h.executeDelegation(sourceID, body.TargetID, delegationID, a2aBody)
|
||||
|
||||
// Broadcast event so canvas shows delegation in real-time
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_SENT", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"target_id": body.TargetID,
|
||||
"task_preview": truncate(body.Task, 100),
|
||||
"task_preview": textutil.TruncateBytes(body.Task, 100),
|
||||
})
|
||||
|
||||
resp := gin.H{
|
||||
@@ -317,7 +318,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
|
||||
// Update status: pending → dispatched
|
||||
h.updateDelegationStatus(sourceID, delegationID, "dispatched", "")
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_STATUS", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationStatus), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID, "target_id": targetID, "status": "dispatched",
|
||||
})
|
||||
|
||||
@@ -352,7 +353,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
log.Printf("Delegation %s: failed to insert error log: %v", delegationID, err)
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_FAILED", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationFailed), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID, "target_id": targetID, "error": proxyErr.Error(),
|
||||
})
|
||||
// RFC #2829 PR-2 result-push (see UpdateStatus for rationale).
|
||||
@@ -388,7 +389,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
`, sourceID, sourceID, targetID, "Delegation queued — target at capacity", string(queuedJSON)); err != nil {
|
||||
log.Printf("Delegation %s: failed to insert queued log: %v", delegationID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_STATUS", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationStatus), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID, "target_id": targetID, "status": "queued",
|
||||
})
|
||||
return
|
||||
@@ -407,7 +408,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
if _, err := db.DB.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 ("+truncate(responseText, 80)+")", string(respJSON)); err != nil {
|
||||
`, sourceID, sourceID, targetID, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON)); err != nil {
|
||||
log.Printf("Delegation %s: failed to insert success log: %v", delegationID, err)
|
||||
}
|
||||
|
||||
@@ -420,10 +421,10 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
// delegation_ledger_integration_test.go.
|
||||
recordLedgerStatus(ctx, delegationID, "completed", "", responseText)
|
||||
h.updateDelegationStatus(sourceID, delegationID, "completed", "")
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"target_id": targetID,
|
||||
"response_preview": truncate(responseText, 200),
|
||||
"response_preview": textutil.TruncateBytes(responseText, 200),
|
||||
})
|
||||
// RFC #2829 PR-2 result-push (see UpdateStatus for rationale).
|
||||
pushDelegationResultToInbox(ctx, sourceID, delegationID, "completed", responseText, "")
|
||||
@@ -503,10 +504,10 @@ func (h *DelegationHandler) Record(c *gin.Context) {
|
||||
recordLedgerInsert(ctx, sourceID, body.TargetID, body.DelegationID, body.Task, "")
|
||||
recordLedgerStatus(ctx, body.DelegationID, "dispatched", "", "")
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_SENT", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
|
||||
"delegation_id": body.DelegationID,
|
||||
"target_id": body.TargetID,
|
||||
"task_preview": truncate(body.Task, 100),
|
||||
"task_preview": textutil.TruncateBytes(body.Task, 100),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
@@ -555,12 +556,12 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
|
||||
if _, err := db.DB.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 ("+truncate(body.ResponsePreview, 80)+")", string(respJSON)); err != nil {
|
||||
`, sourceID, sourceID, "Delegation completed ("+textutil.TruncateBytes(body.ResponsePreview, 80)+")", string(respJSON)); err != nil {
|
||||
log.Printf("Delegation UpdateStatus: result insert failed for %s: %v", delegationID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"response_preview": truncate(body.ResponsePreview, 200),
|
||||
"response_preview": textutil.TruncateBytes(body.ResponsePreview, 200),
|
||||
})
|
||||
// RFC #2829 PR-2 result-push: when the gate is on, also write an
|
||||
// a2a_receive row so the caller's inbox poller surfaces this to
|
||||
@@ -570,7 +571,7 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
|
||||
// the result instead of holding open an HTTP connection.
|
||||
pushDelegationResultToInbox(ctx, sourceID, delegationID, "completed", body.ResponsePreview, "")
|
||||
} else {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_FAILED", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationFailed), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"error": body.Error,
|
||||
})
|
||||
@@ -626,7 +627,7 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) {
|
||||
entry["error"] = errorDetail
|
||||
}
|
||||
if responseBody != "" {
|
||||
entry["response_preview"] = truncate(responseBody, 300)
|
||||
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
|
||||
}
|
||||
delegations = append(delegations, entry)
|
||||
}
|
||||
@@ -727,9 +728,3 @@ func extractResponseText(body []byte) string {
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// delegation_ledger.go — durable per-task ledger for A2A delegation
|
||||
@@ -50,40 +51,15 @@ func NewDelegationLedger(handle *sql.DB) *DelegationLedger {
|
||||
return &DelegationLedger{db: handle}
|
||||
}
|
||||
|
||||
// truncatePreview caps stored preview at 4KB. The full prompt/response is
|
||||
// already in activity_logs.{request,response}_body — this is the at-a-glance
|
||||
// view for the dashboard, not a forensic record.
|
||||
// previewCap caps stored preview at 4KB. The full prompt/response is
|
||||
// already in activity_logs.{request,response}_body — this is the
|
||||
// at-a-glance view for the dashboard, not a forensic record.
|
||||
//
|
||||
// Rune-safe: previous byte-slice form (s[:previewCap]) split on a byte
|
||||
// boundary, which on a multi-byte codepoint at byte 4096 produced
|
||||
// invalid UTF-8 — Postgres JSONB rejects → ledger row not inserted →
|
||||
// audit gap. Issue #2962. Walks the string by rune, stops at the last
|
||||
// rune-boundary index that fits inside the cap. ASCII-only strings hit
|
||||
// the cap exactly; CJK/emoji strings stop slightly under the cap,
|
||||
// never over.
|
||||
//
|
||||
// Mirrors the truncatePreviewRunes fix from agent_message_writer.go
|
||||
// (#2959). Both call sites should consume a shared helper after both
|
||||
// fixes have landed — followup deduplication tracked in #2962's body.
|
||||
// Truncation goes through textutil.TruncateBytesNoMarker so it's
|
||||
// rune-safe (#2026 / #2959 / #2962 bug class: byte-slice mid-codepoint
|
||||
// → Postgres JSONB rejects → silent INSERT failure → audit gap).
|
||||
const previewCap = 4096
|
||||
|
||||
func truncatePreview(s string) string {
|
||||
if len(s) <= previewCap {
|
||||
return s
|
||||
}
|
||||
// Range over a string yields rune-boundary byte indices. Walk
|
||||
// until the next index would exceed previewCap; the previous
|
||||
// index is the safe truncation point.
|
||||
end := 0
|
||||
for i := range s {
|
||||
if i > previewCap {
|
||||
break
|
||||
}
|
||||
end = i
|
||||
}
|
||||
return s[:end]
|
||||
}
|
||||
|
||||
// InsertOpts is the agent's record-of-intent. Caller, callee, task preview,
|
||||
// and the chosen delegation_id are required; idempotency_key is optional.
|
||||
type InsertOpts struct {
|
||||
@@ -118,7 +94,7 @@ func (l *DelegationLedger) Insert(ctx context.Context, opts InsertOpts) {
|
||||
) VALUES ($1, $2, $3, $4, 'queued', $5, $6)
|
||||
ON CONFLICT (delegation_id) DO NOTHING
|
||||
`, opts.DelegationID, opts.CallerID, opts.CalleeID,
|
||||
truncatePreview(opts.TaskPreview), deadline, idemArg)
|
||||
textutil.TruncateBytesNoMarker(opts.TaskPreview, previewCap), deadline, idemArg)
|
||||
if err != nil {
|
||||
log.Printf("delegation_ledger Insert(%s): %v", opts.DelegationID, err)
|
||||
}
|
||||
@@ -197,7 +173,7 @@ func (l *DelegationLedger) SetStatus(ctx context.Context,
|
||||
result_preview = NULLIF($4, ''),
|
||||
updated_at = now()
|
||||
WHERE delegation_id = $1
|
||||
`, delegationID, status, errorDetail, truncatePreview(resultPreview))
|
||||
`, delegationID, status, errorDetail, textutil.TruncateBytesNoMarker(resultPreview, previewCap))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -74,15 +75,20 @@ func TestLedgerInsert_TruncatesOversizedPreview(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
l := NewDelegationLedger(nil)
|
||||
|
||||
huge := strings.Repeat("x", 10_000) // > previewCap
|
||||
// 4096 / 3 = 1365 runes; +10 for margin so we cross the cap.
|
||||
// '世' is 3 bytes in UTF-8 (worst case for byte-cap rune walking).
|
||||
huge := strings.Repeat("世", (previewCap/3)+10)
|
||||
if len(huge) <= previewCap {
|
||||
t.Fatalf("test setup: input too short (%d bytes) — must exceed previewCap=%d", len(huge), previewCap)
|
||||
}
|
||||
|
||||
mock.ExpectExec(`INSERT INTO delegations`).
|
||||
WithArgs(
|
||||
"deleg-big",
|
||||
"c", "ca",
|
||||
sqlmock.AnyArg(), // truncated preview — verify length below via custom matcher
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
capValidUTF8Matcher{cap: previewCap}, // truncated preview must fit cap AND be valid UTF-8
|
||||
sqlmock.AnyArg(), // deadline
|
||||
sqlmock.AnyArg(), // idempotency_key
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -97,87 +103,28 @@ func TestLedgerInsert_TruncatesOversizedPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- truncatePreview unit ----------
|
||||
// capValidUTF8Matcher pins #2962 at the integration boundary: the
|
||||
// preview that lands in the INSERT MUST be valid UTF-8 (else Postgres
|
||||
// JSONB rejects → silent audit gap) AND fit within the byte cap. Pre-
|
||||
// migration this would have asserted on the corrupted "世" mid-codepoint
|
||||
// byte slice; post-migration it asserts the truncated preview is a
|
||||
// clean rune-aligned prefix.
|
||||
type capValidUTF8Matcher struct{ cap int }
|
||||
|
||||
func TestTruncatePreview_UnderCap(t *testing.T) {
|
||||
in := "short"
|
||||
if got := truncatePreview(in); got != in {
|
||||
t.Errorf("under-cap should passthrough; got %q", got)
|
||||
func (m capValidUTF8Matcher) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return len(s) <= m.cap && utf8.ValidString(s)
|
||||
}
|
||||
|
||||
func TestTruncatePreview_OverCapTruncatesAtBoundary(t *testing.T) {
|
||||
in := strings.Repeat("a", previewCap+100)
|
||||
got := truncatePreview(in)
|
||||
if len(got) != previewCap {
|
||||
t.Errorf("expected len=%d got len=%d", previewCap, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatePreview_ExactlyAtCap(t *testing.T) {
|
||||
in := strings.Repeat("a", previewCap)
|
||||
got := truncatePreview(in)
|
||||
if got != in {
|
||||
t.Errorf("at-cap should passthrough unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncatePreview_NeverProducesInvalidUTF8 — pins #2962. The old
|
||||
// byte-slice implementation (s[:previewCap]) split on a byte boundary,
|
||||
// so a multi-byte codepoint straddling byte 4096 produced invalid
|
||||
// UTF-8 → Postgres JSONB rejects → ledger row not inserted → audit
|
||||
// gap. Test feeds a CJK / emoji-padded string longer than previewCap
|
||||
// and asserts utf8.ValidString on the result.
|
||||
func TestTruncatePreview_NeverProducesInvalidUTF8(t *testing.T) {
|
||||
// Build a string of '世' (3 bytes per rune in UTF-8) that's just
|
||||
// past the cap. With the old implementation, the slice at byte
|
||||
// previewCap would land mid-rune and ValidString would fail.
|
||||
// With the rune-aware implementation, the result is always valid
|
||||
// UTF-8 even if the byte length is < previewCap.
|
||||
rune3 := "世" // U+4E16, 3 bytes
|
||||
// Need at least previewCap/3 + 1 runes so we cross the cap with
|
||||
// margin to spare.
|
||||
in := strings.Repeat(rune3, (previewCap/3)+10)
|
||||
if len(in) <= previewCap {
|
||||
t.Fatalf("test setup: input too short (%d bytes) — must exceed previewCap=%d", len(in), previewCap)
|
||||
}
|
||||
got := truncatePreview(in)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("truncatePreview produced invalid UTF-8 — JSONB will reject this row. len(got)=%d", len(got))
|
||||
}
|
||||
if len(got) > previewCap {
|
||||
t.Errorf("truncatePreview exceeded cap: len(got)=%d > previewCap=%d", len(got), previewCap)
|
||||
}
|
||||
// Defense-in-depth: the result should also be a clean rune
|
||||
// prefix of the input — not some garbled sequence.
|
||||
if !strings.HasPrefix(in, got) {
|
||||
t.Errorf("truncatePreview should return a prefix of the input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncatePreview_MultiByteAtBoundary — most-targeted regression.
|
||||
// Feeds an input where the cap byte falls EXACTLY in the middle of a
|
||||
// 3-byte codepoint. Pre-fix, this is the case that produces invalid
|
||||
// UTF-8; post-fix, the truncate stops at the previous rune boundary.
|
||||
func TestTruncatePreview_MultiByteAtBoundary(t *testing.T) {
|
||||
// Build a string that's `previewCap-1` ASCII bytes followed by
|
||||
// '世' (3 bytes). Total = previewCap + 2. The old impl would
|
||||
// slice at byte previewCap, landing inside the '世' codepoint.
|
||||
prefix := strings.Repeat("a", previewCap-1)
|
||||
in := prefix + "世"
|
||||
if len(in) != previewCap+2 {
|
||||
t.Fatalf("test setup: expected len %d, got %d", previewCap+2, len(in))
|
||||
}
|
||||
got := truncatePreview(in)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("truncatePreview produced invalid UTF-8 at the multi-byte boundary case")
|
||||
}
|
||||
// Result should be exactly the ASCII prefix — '世' was past
|
||||
// the cap so it must be dropped entirely.
|
||||
if got != prefix {
|
||||
t.Errorf("expected exact ASCII prefix, got %q (len=%d)", got[len(got)-10:], len(got))
|
||||
}
|
||||
}
|
||||
// Helper-level truncation tests now live in
|
||||
// internal/textutil/truncate_test.go. The integration-level path
|
||||
// (TestLedgerInsert_TruncatesOversizedPreview above) still exercises
|
||||
// the previewCap boundary through the SQL write so a regression in
|
||||
// the wiring (wrong cap, wrong helper, missing call) would still go
|
||||
// red here.
|
||||
|
||||
// ---------- SetStatus lifecycle ----------
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -100,7 +101,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
|
||||
// see when credentials were rotated. No PII; the token plaintext
|
||||
// is NOT logged.
|
||||
if h.broadcaster != nil {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "EXTERNAL_CREDENTIALS_ROTATED", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventExternalCredentialsRotated), id, map[string]interface{}{
|
||||
"workspace_id": id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,43 +331,84 @@ func memoryToView(m contract.Memory) MemoryView {
|
||||
}
|
||||
|
||||
// namespacesToViews converts resolver namespaces into UI-friendly
|
||||
// views. Stable sort: workspace → team → org → custom, then by name.
|
||||
// views. Prefers `DisplayName` from the resolver (workspace.name from
|
||||
// the DB) when present; falls back to a UUID-prefix label.
|
||||
//
|
||||
// Issue #2988: pre-fix, every namespace used a shortID-truncated UUID
|
||||
// label. On a root workspace where workspace==team==org IDs collide
|
||||
// (resolver derive() degenerate case), all three labels rendered
|
||||
// identically. DisplayName disambiguates by surfacing real workspace
|
||||
// names — the canvas dropdown now reads "Workspace (mac laptop)" /
|
||||
// "Team (mac laptop)" / "Org (mac laptop)" for a root workspace
|
||||
// rather than three identical UUID prefixes. The `kind` prefix
|
||||
// "Workspace/Team/Org" still carries the semantic distinction.
|
||||
func namespacesToViews(in []namespace.Namespace) []NamespaceView {
|
||||
views := make([]NamespaceView, 0, len(in))
|
||||
for _, n := range in {
|
||||
views = append(views, NamespaceView{
|
||||
Name: n.Name,
|
||||
Kind: n.Kind,
|
||||
Label: namespaceLabel(n.Name, n.Kind),
|
||||
Label: namespaceLabelWithName(n.Name, n.Kind, n.DisplayName),
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// namespaceLabel renders a human-friendly label for a namespace. The
|
||||
// canvas displays this directly; we keep the formatting server-side
|
||||
// so the shape stays consistent across UIs (canvas, future TUI, etc.).
|
||||
// namespaceLabel renders a human-friendly label for a namespace using
|
||||
// the UUID-prefix fallback only. Kept for back-compat with callers
|
||||
// that don't yet plumb a display name. New callers should use
|
||||
// namespaceLabelWithName which prefers the workspace's display name
|
||||
// when available.
|
||||
//
|
||||
// Format:
|
||||
// workspace:abc-123 → "Workspace (abc-123)" (UUID short-prefixed)
|
||||
// Format (UUID-prefix fallback):
|
||||
// workspace:abc-123 → "Workspace (abc-123)"
|
||||
// team:t-1 → "Team (t-1)"
|
||||
// org:acme → "Org (acme)"
|
||||
// custom:foo → "foo" (operator-defined; raw)
|
||||
// custom:foo → "foo"
|
||||
func namespaceLabel(name string, kind contract.NamespaceKind) string {
|
||||
return namespaceLabelWithName(name, kind, "")
|
||||
}
|
||||
|
||||
// namespaceLabelWithName renders the human-friendly label, preferring
|
||||
// `displayName` when non-empty.
|
||||
//
|
||||
// When displayName is set:
|
||||
// Workspace, "mac laptop" → "Workspace (mac laptop)"
|
||||
// Team, "Engineering team" → "Team (Engineering team)"
|
||||
// Org, "Hongming's Org" → "Org (Hongming's Org)"
|
||||
//
|
||||
// When displayName is empty (lookup miss, future-migration drop, etc.),
|
||||
// falls back to the UUID-prefix shape for back-compat.
|
||||
//
|
||||
// Custom namespaces ignore displayName because they're operator-defined
|
||||
// — the operator chose the raw suffix as the label, surfacing a
|
||||
// different "name" would be a UX surprise.
|
||||
func namespaceLabelWithName(name string, kind contract.NamespaceKind, displayName string) string {
|
||||
suffix := ""
|
||||
if i := indexOfColon(name); i >= 0 && i+1 < len(name) {
|
||||
suffix = name[i+1:]
|
||||
}
|
||||
switch kind {
|
||||
case contract.NamespaceKindWorkspace:
|
||||
if displayName != "" {
|
||||
return "Workspace (" + displayName + ")"
|
||||
}
|
||||
return "Workspace (" + shortID(suffix) + ")"
|
||||
case contract.NamespaceKindTeam:
|
||||
if displayName != "" {
|
||||
return "Team (" + displayName + ")"
|
||||
}
|
||||
return "Team (" + shortID(suffix) + ")"
|
||||
case contract.NamespaceKindOrg:
|
||||
if displayName != "" {
|
||||
return "Org (" + displayName + ")"
|
||||
}
|
||||
return "Org (" + suffix + ")"
|
||||
case contract.NamespaceKindCustom:
|
||||
// Custom namespaces are operator-defined; surface the raw
|
||||
// suffix so they can label them however they want.
|
||||
// Operator-defined; the suffix IS the label they chose.
|
||||
// displayName is ignored — surfacing a different name would
|
||||
// be a UX surprise for an operator who deliberately named
|
||||
// the namespace.
|
||||
if suffix == "" {
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -507,6 +507,92 @@ func TestMemoriesV2_Forget_MissingMemoryID_400(t *testing.T) {
|
||||
// View-shaping unit tests — pin individual helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// namespaceLabelWithName tests — the new code path that prefers
|
||||
// DisplayName over UUID-prefix fallback (issue #2988).
|
||||
func TestNamespaceLabelWithName_PrefersDisplayNameWhenSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
kind contract.NamespaceKind
|
||||
display string
|
||||
want string
|
||||
}{
|
||||
{"workspace with name", "workspace:abc-1234", contract.NamespaceKindWorkspace, "mac laptop", "Workspace (mac laptop)"},
|
||||
{"team with name", "team:abc-1234", contract.NamespaceKindTeam, "Engineering", "Team (Engineering)"},
|
||||
{"org with name", "org:acme", contract.NamespaceKindOrg, "Hongming's Org", "Org (Hongming's Org)"},
|
||||
// Custom ignores displayName by design — operator chose the suffix.
|
||||
{"custom ignores displayName", "custom:ops-shared", contract.NamespaceKindCustom, "FancyName", "ops-shared"},
|
||||
{"unknown kind falls through", "weird:x", contract.NamespaceKind("future"), "WhoCares", "weird:x"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := namespaceLabelWithName(tc.raw, tc.kind, tc.display)
|
||||
if got != tc.want {
|
||||
t.Errorf("namespaceLabelWithName(%q, %q, %q) = %q, want %q",
|
||||
tc.raw, tc.kind, tc.display, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceLabelWithName_FallsBackToUUIDPrefixWhenEmpty(t *testing.T) {
|
||||
// When displayName is empty (NULL in DB, lookup miss, etc.), the
|
||||
// label shape MUST match the legacy UUID-prefix shape exactly so
|
||||
// existing canvas behaviour is unchanged for callers that don't
|
||||
// plumb a name.
|
||||
cases := []struct {
|
||||
raw string
|
||||
kind contract.NamespaceKind
|
||||
want string
|
||||
}{
|
||||
{"workspace:abcdefghij", contract.NamespaceKindWorkspace, "Workspace (abcdefgh)"},
|
||||
{"team:t-99", contract.NamespaceKindTeam, "Team (t-99)"},
|
||||
{"org:acme", contract.NamespaceKindOrg, "Org (acme)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := namespaceLabelWithName(tc.raw, tc.kind, "")
|
||||
if got != tc.want {
|
||||
t.Errorf("displayName=\"\" path: got %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacesToViews_PassesDisplayNameThrough(t *testing.T) {
|
||||
in := []namespace.Namespace{
|
||||
{Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace, DisplayName: "mac laptop"},
|
||||
{Name: "team:root-1", Kind: contract.NamespaceKindTeam, DisplayName: "mac laptop"}, // root → team aliases self
|
||||
{Name: "org:root-1", Kind: contract.NamespaceKindOrg, DisplayName: "mac laptop"},
|
||||
}
|
||||
out := namespacesToViews(in)
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(out))
|
||||
}
|
||||
wantLabels := []string{
|
||||
"Workspace (mac laptop)",
|
||||
"Team (mac laptop)",
|
||||
"Org (mac laptop)",
|
||||
}
|
||||
for i, v := range out {
|
||||
if v.Label != wantLabels[i] {
|
||||
t.Errorf("[%d] label = %q, want %q", i, v.Label, wantLabels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacesToViews_FallsBackToUUIDLabelWhenDisplayNameEmpty(t *testing.T) {
|
||||
// Exercises the back-compat path — DisplayName="" plumbs through
|
||||
// to namespaceLabelWithName which returns the legacy UUID-prefix
|
||||
// label. This is what callers see when the workspaces table
|
||||
// has a NULL name (defensive — workspaces.name is NOT NULL today).
|
||||
in := []namespace.Namespace{
|
||||
{Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace}, // no DisplayName
|
||||
}
|
||||
out := namespacesToViews(in)
|
||||
if out[0].Label != "Workspace (root-1)" {
|
||||
t.Errorf("fallback label = %q, want %q", out[0].Label, "Workspace (root-1)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceLabel_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -20,12 +20,14 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provlog"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// createWorkspaceTree recursively materialises an OrgWorkspace (and its
|
||||
// descendants) into the workspaces + canvas_layouts tables and kicks off
|
||||
// Docker provisioning. absX/absY are THIS workspace's absolute canvas
|
||||
@@ -80,61 +82,6 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
}
|
||||
}
|
||||
|
||||
// 5s timeout bounds the lookup independently of any HTTP request
|
||||
// context. createWorkspaceTree runs in goroutines spawned from the
|
||||
// /org/import handler, so plumbing the request context here would
|
||||
// cascade-cancel into provisionWorkspaceAuto and abort in-flight
|
||||
// EC2 provisioning if the client disconnected mid-import — that's
|
||||
// the wrong behaviour. A short bounded timeout protects the
|
||||
// per-row SELECT against a wedged DB without taking the
|
||||
// drop-everything-on-disconnect tradeoff.
|
||||
ctxLookup, cancelLookup := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelLookup()
|
||||
// Idempotency: if a workspace with the same (parent_id, name) already
|
||||
// exists, skip the INSERT + canvas_layouts + broadcast + provisioning.
|
||||
// This is what makes /org/import safe to call multiple times — the
|
||||
// historical leak was every call recreating the entire tree (see
|
||||
// tenant-hongming, 72 distinct child workspaces in 4 days, all from
|
||||
// repeated org-template spawns of the same template).
|
||||
//
|
||||
// Recursion still runs on the existing id so partial-match templates
|
||||
// (parent exists, some children missing) backfill the missing children
|
||||
// instead of either no-op'ing the whole subtree or duplicating the
|
||||
// existing children.
|
||||
//
|
||||
// /org/import is ADDITIVE-ONLY, never destructive. Children present
|
||||
// in the existing tree but absent from the new template are
|
||||
// preserved (no DELETE on diff). Skip-path also does NOT propagate
|
||||
// updates to existing nodes — a re-import that adds an
|
||||
// initial_memory or schedule to an existing workspace is silently
|
||||
// dropped (the function bypasses seedInitialMemories, schedule SQL,
|
||||
// channel config for skipped rows). To force-update an existing
|
||||
// tree, delete and re-import or use a future /org/sync route.
|
||||
existingID, existing, lookupErr := h.lookupExistingChild(ctxLookup, ws.Name, parentID)
|
||||
if lookupErr != nil {
|
||||
return fmt.Errorf("idempotency check for %s: %w", ws.Name, lookupErr)
|
||||
}
|
||||
if existing {
|
||||
log.Printf("Org import: %q already exists (id=%s) — skipping create+provision, recursing into children for partial-match", ws.Name, existingID)
|
||||
parentRef := ""
|
||||
if parentID != nil {
|
||||
parentRef = *parentID
|
||||
}
|
||||
provlog.Event("provision.skip_existing", map[string]any{
|
||||
"name": ws.Name,
|
||||
"existing_id": existingID,
|
||||
"parent_id": parentRef,
|
||||
"tier": tier,
|
||||
})
|
||||
*results = append(*results, map[string]interface{}{
|
||||
"id": existingID,
|
||||
"name": ws.Name,
|
||||
"tier": tier,
|
||||
"skipped": true,
|
||||
})
|
||||
return h.recurseChildrenForImport(ws, existingID, absX, absY, defaults, orgBaseDir, results, provisionSem)
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
awarenessNS := workspaceAwarenessNamespace(id)
|
||||
|
||||
@@ -186,10 +133,67 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
if maxConcurrent <= 0 {
|
||||
maxConcurrent = models.DefaultMaxConcurrentTasks
|
||||
}
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
// TOCTOU-safe insert (#2872 Critical 1).
|
||||
//
|
||||
// `ON CONFLICT DO NOTHING` paired with the partial unique index
|
||||
// from migration 20260506000000_workspaces_unique_parent_name.up.sql
|
||||
// atomically resolves a race window that the prior
|
||||
// lookup-then-insert had: two concurrent /org/import POSTs both
|
||||
// saw "not found" in lookupExistingChild and both INSERT'd the
|
||||
// same (parent_id, name). After this swap the SECOND INSERT
|
||||
// silently no-ops, RETURNING returns 0 rows → sql.ErrNoRows, and
|
||||
// the skip-path runs.
|
||||
//
|
||||
// ON CONFLICT target uses (COALESCE(parent_id,...), name) WHERE
|
||||
// status != 'removed' — must match the partial-index predicate
|
||||
// EXACTLY for Postgres to consider the index applicable.
|
||||
var insertedID string
|
||||
err := db.DB.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)
|
||||
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent)
|
||||
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
|
||||
WHERE status != 'removed'
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Skip path — a non-removed row already exists for
|
||||
// (parent_id, name). Re-select its id; idempotency-friendly
|
||||
// semantics match the original lookupExistingChild path
|
||||
// (parent_id IS NOT DISTINCT FROM matches NULL too,
|
||||
// status='removed' rows are ignored).
|
||||
ctxLookup, cancelLookup := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelLookup()
|
||||
existingID, found, selErr := h.lookupExistingChild(ctxLookup, ws.Name, parentID)
|
||||
if selErr != nil {
|
||||
return fmt.Errorf("post-conflict re-select for %s: %w", ws.Name, selErr)
|
||||
}
|
||||
if !found {
|
||||
// Index conflicted but row vanished between INSERT and
|
||||
// re-SELECT (status flipped to 'removed' concurrently).
|
||||
// Surface as an error rather than silently retrying —
|
||||
// the user can re-trigger /org/import safely.
|
||||
return fmt.Errorf("workspace %q conflicted on insert but not visible on re-select (concurrent status flip?)", ws.Name)
|
||||
}
|
||||
log.Printf("Org import: %q already exists (id=%s) — skipping create+provision, recursing into children for partial-match", ws.Name, existingID)
|
||||
parentRef := ""
|
||||
if parentID != nil {
|
||||
parentRef = *parentID
|
||||
}
|
||||
provlog.Event("provision.skip_existing", map[string]any{
|
||||
"name": ws.Name,
|
||||
"existing_id": existingID,
|
||||
"parent_id": parentRef,
|
||||
"tier": tier,
|
||||
})
|
||||
*results = append(*results, map[string]interface{}{
|
||||
"id": existingID,
|
||||
"name": ws.Name,
|
||||
"tier": tier,
|
||||
"skipped": true,
|
||||
})
|
||||
return h.recurseChildrenForImport(ws, existingID, absX, absY, defaults, orgBaseDir, results, provisionSem)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Org import: failed to create %s: %v", ws.Name, err)
|
||||
return fmt.Errorf("failed to create %s: %w", ws.Name, err)
|
||||
@@ -227,7 +231,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
if parentID != nil {
|
||||
payload["parent_id"] = *parentID
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, payload)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), id, payload)
|
||||
|
||||
// Seed initial memories from workspace config or defaults (issue #1050).
|
||||
// Per-workspace initial_memories override defaults; if workspace has none,
|
||||
@@ -243,7 +247,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
if _, err := db.DB.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, "WORKSPACE_ONLINE", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), id, map[string]interface{}{
|
||||
"name": ws.Name, "external": true,
|
||||
})
|
||||
} else if h.workspace.HasProvisioner() {
|
||||
|
||||
@@ -31,11 +31,25 @@ import (
|
||||
// tests pin the helper's three observable behaviors plus an AST gate
|
||||
// that catches future re-introductions of the un-checked INSERT.
|
||||
|
||||
// lookupChildSQLRE anchors the sqlmock ExpectQuery on every load-bearing
|
||||
// token of lookupExistingChild's SELECT (org_import.go:639-645). A loose
|
||||
// substring match (the prior shape, just `SELECT id FROM workspaces`)
|
||||
// would silent-pass a regression that drops `IS NOT DISTINCT FROM`
|
||||
// (breaks NULL-parent matching), drops `parent_id` entirely (hijacks
|
||||
// siblings of the same name across different parents), or drops the
|
||||
// `status != 'removed'` filter (blocks re-import after Collapse).
|
||||
// RFC #2872 Important-2.
|
||||
//
|
||||
// The four anchored tokens are exactly the predicates the bug shapes
|
||||
// would tamper with. Whitespace is `\s+` so a future formatter pass
|
||||
// doesn't churn this string.
|
||||
const lookupChildSQLRE = `(?s)SELECT id FROM workspaces\s+WHERE name = \$1\s+AND parent_id IS NOT DISTINCT FROM \$2\s+AND status != 'removed'`
|
||||
|
||||
func TestLookupExistingChild_NotFound_ReturnsFalseNoError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
// 0-row result → driver returns sql.ErrNoRows on Scan.
|
||||
parent := "parent-1"
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
||||
mock.ExpectQuery(lookupChildSQLRE).
|
||||
WithArgs("Alpha", &parent).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
|
||||
@@ -56,7 +70,7 @@ func TestLookupExistingChild_NotFound_ReturnsFalseNoError(t *testing.T) {
|
||||
func TestLookupExistingChild_Found_ReturnsIDAndTrue(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
parent := "parent-1"
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
||||
mock.ExpectQuery(lookupChildSQLRE).
|
||||
WithArgs("Alpha", &parent).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-existing-uuid"))
|
||||
|
||||
@@ -79,7 +93,7 @@ func TestLookupExistingChild_NilParent_MatchesRoot(t *testing.T) {
|
||||
// a plain `=` would never match a NULL row. Pin that roots
|
||||
// (parent_id=NULL) are still found by the lookup.
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
||||
mock.ExpectQuery(lookupChildSQLRE).
|
||||
WithArgs("RootAgent", (*string)(nil)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-root-uuid"))
|
||||
|
||||
@@ -102,7 +116,7 @@ func TestLookupExistingChild_DBError_Propagates(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
parent := "parent-1"
|
||||
connFail := errors.New("simulated postgres unavailable")
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
||||
mock.ExpectQuery(lookupChildSQLRE).
|
||||
WithArgs("Alpha", &parent).
|
||||
WillReturnError(connFail)
|
||||
|
||||
@@ -137,7 +151,7 @@ func TestLookupExistingChild_WrappedNoRows_TreatedAsNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
parent := "parent-1"
|
||||
wrapped := fmt.Errorf("driver-wrapped: %w", sql.ErrNoRows)
|
||||
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
||||
mock.ExpectQuery(lookupChildSQLRE).
|
||||
WithArgs("Alpha", &parent).
|
||||
WillReturnError(wrapped)
|
||||
|
||||
@@ -209,19 +223,42 @@ func findLookupAndWorkspacesInsertPos(t *testing.T, fname string, src []byte) (l
|
||||
return
|
||||
}
|
||||
|
||||
// Source-level guard — pins that org_import.go calls
|
||||
// h.lookupExistingChild BEFORE its INSERT INTO workspaces.
|
||||
// onConflictDoNothingRE pins the TOCTOU-safe shape introduced by
|
||||
// migration 20260506000000_workspaces_unique_parent_name.up.sql +
|
||||
// the org_import.go INSERT swap (#2872 Critical 1). The workspaces
|
||||
// INSERT MUST funnel concurrent collisions through the partial unique
|
||||
// index — `ON CONFLICT (...) WHERE status != 'removed' DO NOTHING`
|
||||
// is the literal pg statement form that achieves it.
|
||||
//
|
||||
// The pattern intentionally requires both the COALESCE expression
|
||||
// (so root-workspace NULL parents collide) AND the partial-index WHERE
|
||||
// clause (so 'removed' rows don't block re-imports). A regression that
|
||||
// drops either piece would make the index target a different shape
|
||||
// than the migration created, and Postgres would emit
|
||||
// "no unique or exclusion constraint matching the ON CONFLICT
|
||||
// specification" at runtime — but only on the FIRST collision attempt
|
||||
// in production, not in CI without a live race. This regex catches
|
||||
// the shape in source so the bug never ships.
|
||||
var onConflictDoNothingRE = regexp.MustCompile(
|
||||
`(?s)ON\s+CONFLICT\s*\(\s*COALESCE\s*\(\s*parent_id\s*,\s*'00000000-0000-0000-0000-000000000000'::uuid\s*\)\s*,\s*name\s*\).*?WHERE\s+status\s*!=\s*'removed'.*?DO\s+NOTHING`,
|
||||
)
|
||||
|
||||
// Source-level guard — pins that org_import.go's INSERT INTO workspaces
|
||||
// uses the TOCTOU-safe ON CONFLICT DO NOTHING pattern.
|
||||
//
|
||||
// Per memory feedback_behavior_based_ast_gates.md: pin the behavior
|
||||
// (idempotency check before INSERT), not just function names. If a
|
||||
// future refactor reintroduces the un-checked INSERT (the original
|
||||
// bug shape that leaked 72 workspaces in 4 days), this test fails.
|
||||
// (atomic conflict resolution at the DB), not just function names.
|
||||
// If a future refactor reintroduces the un-checked INSERT (the original
|
||||
// bug shape that leaked 72 workspaces in 4 days at tenant-hongming),
|
||||
// this test fails BEFORE the broken code reaches production where the
|
||||
// race window opens.
|
||||
//
|
||||
// AST-walk implementation closes the silent-false-pass mode that the
|
||||
// previous bytes.Index gate had — see workspacesInsertRE comment for
|
||||
// the failure mode (workspaces_audit / workspace_secrets / etc.
|
||||
// shadowing the real target via prefix match).
|
||||
func TestCreateWorkspaceTree_CallsLookupBeforeInsert(t *testing.T) {
|
||||
// Replaces an earlier "lookup-before-insert" gate that became obsolete
|
||||
// when this swap moved idempotency into the database. The earlier
|
||||
// gate would silent-false-pass against ON CONFLICT — even though that
|
||||
// shape is correct — because lookupExistingChild now runs AFTER the
|
||||
// INSERT (only on the skip path, to retrieve the existing id).
|
||||
func TestCreateWorkspaceTree_InsertUsesOnConflictDoNothing(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
@@ -230,30 +267,24 @@ func TestCreateWorkspaceTree_CallsLookupBeforeInsert(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read org_import.go: %v", err)
|
||||
}
|
||||
lookupPos, insertPos, fset := findLookupAndWorkspacesInsertPos(t, "org_import.go", src)
|
||||
|
||||
if lookupPos == token.NoPos {
|
||||
t.Fatalf("AST: no call to lookupExistingChild in org_import.go — idempotency check removed?")
|
||||
}
|
||||
if insertPos == token.NoPos {
|
||||
insertSQL := findWorkspacesInsertSQL(t, "org_import.go", src)
|
||||
if insertSQL == "" {
|
||||
t.Fatalf("AST: no SQL literal matching `^\\s*INSERT INTO workspaces\\s*\\(` in any CallExpr in org_import.go — schema change or rename?")
|
||||
}
|
||||
if lookupPos > insertPos {
|
||||
t.Errorf("lookupExistingChild call at %s must come BEFORE INSERT INTO workspaces at %s — non-idempotent ordering would re-leak under repeat /org/import calls",
|
||||
fset.Position(lookupPos), fset.Position(insertPos))
|
||||
if !onConflictDoNothingRE.MatchString(insertSQL) {
|
||||
t.Errorf("workspaces INSERT SQL does NOT use the TOCTOU-safe ON CONFLICT shape — concurrent /org/import POSTs will silently double-insert. Required pattern:\n ON CONFLICT (COALESCE(parent_id, '00000000-...'::uuid), name) WHERE status != 'removed' DO NOTHING\n\nActual SQL:\n%s", insertSQL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_FailsWhenLookupAfterInsert proves the gate actually catches
|
||||
// the bug it's named after — running it against synthetic Go source
|
||||
// where the lookup call is positioned AFTER the workspaces INSERT must
|
||||
// produce lookupPos > insertPos, which the production gate flags as
|
||||
// an ERROR. Without this test the gate could regress to "always pass"
|
||||
// and we wouldn't notice until the bug shipped again.
|
||||
// TestGate_FailsWhenInsertOmitsOnConflict proves the gate actually
|
||||
// catches the bug it's named after — running it against synthetic Go
|
||||
// source where the workspaces INSERT lacks the ON CONFLICT clause must
|
||||
// fail the regex match. Without this test the gate could regress to
|
||||
// "always pass" and the TOCTOU window would silently reopen.
|
||||
//
|
||||
// Per memory feedback_assert_exact_not_substring.md: verify a
|
||||
// tightened test FAILS on old code before merging.
|
||||
func TestGate_FailsWhenLookupAfterInsert(t *testing.T) {
|
||||
// Per memory feedback_assert_exact_not_substring.md: verify the
|
||||
// tightened test FAILS on the bug shape before merging.
|
||||
func TestGate_FailsWhenInsertOmitsOnConflict(t *testing.T) {
|
||||
const buggySrc = `package handlers
|
||||
|
||||
import "context"
|
||||
@@ -264,26 +295,57 @@ func (fakeDB) ExecContext(ctx context.Context, sql string, args ...interface{})
|
||||
|
||||
type fakeOrgHandler struct{}
|
||||
|
||||
func (h *fakeOrgHandler) lookupExistingChild(ctx context.Context, name string, parentID *string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func buggyCreate(h *fakeOrgHandler, db fakeDB, ctx context.Context, name string, parentID *string) {
|
||||
// Bug shape: INSERT runs FIRST, lookup runs AFTER. This is the
|
||||
// non-idempotent ordering the gate exists to forbid.
|
||||
// Bug shape: bare INSERT, no ON CONFLICT. Two concurrent calls
|
||||
// race past the unique-index check before either completes the
|
||||
// transaction; constraint failure surfaces as a 500 to the
|
||||
// caller (not graceful skip). Pre-#2872 this would silently
|
||||
// duplicate-insert.
|
||||
db.ExecContext(ctx, ` + "`INSERT INTO workspaces (id, name) VALUES ($1, $2)`" + `, "x", name)
|
||||
h.lookupExistingChild(ctx, name, parentID)
|
||||
}
|
||||
`
|
||||
lookupPos, insertPos, _ := findLookupAndWorkspacesInsertPos(t, "buggy.go", []byte(buggySrc))
|
||||
if lookupPos == token.NoPos || insertPos == token.NoPos {
|
||||
t.Fatalf("synthetic buggy source missing expected nodes (lookupPos=%v insertPos=%v) — helper logic regression", lookupPos, insertPos)
|
||||
insertSQL := findWorkspacesInsertSQL(t, "buggy.go", []byte(buggySrc))
|
||||
if insertSQL == "" {
|
||||
t.Fatalf("synthetic buggy source missing workspaces INSERT — helper logic regression")
|
||||
}
|
||||
if lookupPos < insertPos {
|
||||
t.Fatalf("synthetic bug shape (lookup AFTER insert) returned lookupPos=%d < insertPos=%d — gate would NOT fire on actual bug, regression!", lookupPos, insertPos)
|
||||
if onConflictDoNothingRE.MatchString(insertSQL) {
|
||||
t.Fatalf("synthetic bug shape (bare INSERT, no ON CONFLICT) was MATCHED by the gate — regression: gate would not flag the actual bug. SQL:\n%s", insertSQL)
|
||||
}
|
||||
// Implicit: lookupPos > insertPos here, which the production gate
|
||||
// flags via t.Errorf. This proves the gate is live, not vestigial.
|
||||
}
|
||||
|
||||
// findWorkspacesInsertSQL walks `src` and returns the unquoted SQL of
|
||||
// the first string literal matching workspacesInsertRE inside any
|
||||
// CallExpr's argument list. Returns "" if none found. Helper for the
|
||||
// ON CONFLICT gate above.
|
||||
func findWorkspacesInsertSQL(t *testing.T, fname string, src []byte) string {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, fname, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", fname, err)
|
||||
}
|
||||
var sql string
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for _, arg := range call.Args {
|
||||
lit, ok := arg.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
continue
|
||||
}
|
||||
raw := lit.Value
|
||||
if unq, err := strconv.Unquote(raw); err == nil {
|
||||
raw = unq
|
||||
}
|
||||
if workspacesInsertRE.MatchString(raw) && sql == "" {
|
||||
sql = raw
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return sql
|
||||
}
|
||||
|
||||
// TestGate_IgnoresAuditTableShadow proves the regex tightening
|
||||
|
||||
@@ -451,6 +451,201 @@ func TestIntegration_PendingUploads_AckedIndexExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_PollUpload_AtomicRollback_AcrossBothTables proves the
|
||||
// #149 cross-table contract at the database layer: when PutBatchTx and
|
||||
// LogActivityTx run in the same caller-owned Tx and an activity INSERT
|
||||
// fails after some rows have already been INSERTed, Rollback unwinds
|
||||
// BOTH tables, leaving zero rows.
|
||||
//
|
||||
// Coverage map (#149):
|
||||
// - chat_files_poll_test.go's TestPollUpload_AtomicRollbackOnActivityInsertFailure
|
||||
// uses sqlmock to prove the Go handler issues Begin / N inserts /
|
||||
// Rollback in the right order (no Commit on failure path).
|
||||
// - This integration test proves the helpers + real Postgres compose
|
||||
// correctly: rollback after a mid-Tx activity insert failure
|
||||
// actually reverts BOTH the prior activity row AND the
|
||||
// pending_uploads rows from PutBatchTx.
|
||||
// - The pre-existing TestIntegration_PendingUploads_PutBatch_AtomicRollback
|
||||
// covers the pending_uploads-only case.
|
||||
//
|
||||
// Failure injection: a NUL byte in `summary` (TEXT column) — lib/pq
|
||||
// rejects it at the protocol layer. Same trick the existing PutBatch
|
||||
// AtomicRollback test uses for the pending_uploads INSERT.
|
||||
func TestIntegration_PollUpload_AtomicRollback_AcrossBothTables(t *testing.T) {
|
||||
conn := integrationDB_PendingUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// activity_logs has a FK to workspaces(id) — seed a real row so
|
||||
// non-failing inserts succeed. Wipe activity_logs + this workspaces
|
||||
// row at end so the next test sees a clean slate (the integrationDB
|
||||
// helper only wipes pending_uploads).
|
||||
wsID := uuid.New()
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO workspaces (id, name) VALUES ($1, 'test-149-rollback')`, wsID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed workspace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
// CASCADE on workspaces FK deletes the activity_logs rows; explicit
|
||||
// DELETE on activity_logs catches any rows that somehow leaked.
|
||||
_, _ = conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id = $1`, wsID)
|
||||
_, _ = conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id = $1`, wsID)
|
||||
})
|
||||
|
||||
store := pendinguploads.NewPostgres(conn)
|
||||
|
||||
// Mirror uploadPollMode's Tx shape: BeginTx → PutBatchTx → N ×
|
||||
// LogActivityTx → Commit (or Rollback on failure).
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
items := []pendinguploads.PutItem{
|
||||
{Content: []byte("first"), Filename: "a.txt", Mimetype: "text/plain"},
|
||||
{Content: []byte("second"), Filename: "b.txt", Mimetype: "text/plain"},
|
||||
}
|
||||
fileIDs, err := store.PutBatchTx(ctx, tx, wsID, items)
|
||||
if err != nil {
|
||||
t.Fatalf("PutBatchTx: %v", err)
|
||||
}
|
||||
if len(fileIDs) != 2 {
|
||||
t.Fatalf("len(fileIDs) = %d, want 2", len(fileIDs))
|
||||
}
|
||||
|
||||
// First activity insert succeeds — would commit if not for the
|
||||
// rollback that the second insert's failure forces.
|
||||
wsIDStr := wsID.String()
|
||||
method := "chat_upload_receive"
|
||||
okSummary := "chat_upload_receive: a.txt"
|
||||
if _, err := LogActivityTx(ctx, tx, nil, ActivityParams{
|
||||
WorkspaceID: wsIDStr,
|
||||
ActivityType: "a2a_receive",
|
||||
TargetID: &wsIDStr,
|
||||
Method: &method,
|
||||
Summary: &okSummary,
|
||||
Status: "ok",
|
||||
}); err != nil {
|
||||
t.Fatalf("first LogActivityTx (should succeed): %v", err)
|
||||
}
|
||||
|
||||
// Second activity insert: NUL byte in summary triggers lib/pq
|
||||
// "invalid byte sequence for encoding UTF8: 0x00" — the canonical
|
||||
// "DB-side error after some Tx work has already happened" we want.
|
||||
badSummary := "chat_upload_receive: b\x00.txt"
|
||||
_, err = LogActivityTx(ctx, tx, nil, ActivityParams{
|
||||
WorkspaceID: wsIDStr,
|
||||
ActivityType: "a2a_receive",
|
||||
TargetID: &wsIDStr,
|
||||
Method: &method,
|
||||
Summary: &badSummary,
|
||||
Status: "ok",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from NUL-byte summary, got nil")
|
||||
}
|
||||
|
||||
// Caller (uploadPollMode in production) rolls back on the error.
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
t.Fatalf("Rollback: %v", rbErr)
|
||||
}
|
||||
|
||||
// THE assertion this test exists for: BOTH tables must have zero
|
||||
// rows for this workspace. Pre-#149 the activity_logs row from the
|
||||
// first insert would persist (separate fire-and-forget INSERT) and
|
||||
// pending_uploads would also persist (committed by PutBatch's own
|
||||
// Tx). Post-#149 the shared Tx + Rollback unwinds both.
|
||||
var puCount, alCount int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pending_uploads WHERE workspace_id = $1`, wsID,
|
||||
).Scan(&puCount); err != nil {
|
||||
t.Fatalf("count pending_uploads: %v", err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM activity_logs WHERE workspace_id = $1`, wsID,
|
||||
).Scan(&alCount); err != nil {
|
||||
t.Fatalf("count activity_logs: %v", err)
|
||||
}
|
||||
if puCount != 0 {
|
||||
t.Errorf("pending_uploads leaked %d row(s) after Rollback — #149 regression", puCount)
|
||||
}
|
||||
if alCount != 0 {
|
||||
t.Errorf("activity_logs leaked %d row(s) after Rollback — #149 regression "+
|
||||
"(THIS is the scenario the ticket called out: pre-fix, the first activity row "+
|
||||
"committed in its own implicit Tx, leaving an orphan)", alCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_PollUpload_HappyPath_AcrossBothTables is the positive
|
||||
// counterpart to the rollback test: when nothing fails, both tables
|
||||
// commit together and the row counts match.
|
||||
func TestIntegration_PollUpload_HappyPath_AcrossBothTables(t *testing.T) {
|
||||
conn := integrationDB_PendingUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
wsID := uuid.New()
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO workspaces (id, name) VALUES ($1, 'test-149-happy')`, wsID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed workspace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id = $1`, wsID)
|
||||
_, _ = conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id = $1`, wsID)
|
||||
})
|
||||
|
||||
store := pendinguploads.NewPostgres(conn)
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
items := []pendinguploads.PutItem{
|
||||
{Content: []byte("a"), Filename: "a.txt", Mimetype: "text/plain"},
|
||||
{Content: []byte("b"), Filename: "b.txt", Mimetype: "text/plain"},
|
||||
{Content: []byte("c"), Filename: "c.txt", Mimetype: "text/plain"},
|
||||
}
|
||||
if _, err := store.PutBatchTx(ctx, tx, wsID, items); err != nil {
|
||||
t.Fatalf("PutBatchTx: %v", err)
|
||||
}
|
||||
wsIDStr := wsID.String()
|
||||
method := "chat_upload_receive"
|
||||
for _, it := range items {
|
||||
summary := "chat_upload_receive: " + it.Filename
|
||||
if _, err := LogActivityTx(ctx, tx, nil, ActivityParams{
|
||||
WorkspaceID: wsIDStr,
|
||||
ActivityType: "a2a_receive",
|
||||
TargetID: &wsIDStr,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: "ok",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogActivityTx %q: %v", it.Filename, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
|
||||
var puCount, alCount int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pending_uploads WHERE workspace_id = $1`, wsID,
|
||||
).Scan(&puCount); err != nil {
|
||||
t.Fatalf("count pending_uploads: %v", err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM activity_logs WHERE workspace_id = $1`, wsID,
|
||||
).Scan(&alCount); err != nil {
|
||||
t.Fatalf("count activity_logs: %v", err)
|
||||
}
|
||||
if puCount != 3 {
|
||||
t.Errorf("pending_uploads count = %d, want 3", puCount)
|
||||
}
|
||||
if alCount != 3 {
|
||||
t.Errorf("activity_logs count = %d, want 3", alCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_PendingUploads_GetIgnoresExpiredAndAcked(t *testing.T) {
|
||||
conn := integrationDB_PendingUploads(t)
|
||||
store := pendinguploads.NewPostgres(conn)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -84,6 +85,9 @@ func (f *fakeStorage) Sweep(_ context.Context, _ time.Duration) (pendinguploads.
|
||||
func (f *fakeStorage) PutBatch(_ context.Context, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStorage) PutBatchTx(_ context.Context, _ *sql.Tx, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newRouter(handler *handlers.PendingUploadsHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -414,7 +414,7 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Broadcast WORKSPACE_ONLINE
|
||||
if err := h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.ID, map[string]interface{}{
|
||||
if err := h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.ID, map[string]interface{}{
|
||||
"url": cachedURL,
|
||||
"agent_card": payload.AgentCard,
|
||||
"delivery_mode": effectiveMode,
|
||||
@@ -572,7 +572,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
|
||||
|
||||
// Broadcast current task update only when it changed (avoid spamming on every heartbeat)
|
||||
if payload.CurrentTask != prevTask {
|
||||
h.broadcaster.BroadcastOnly(payload.WorkspaceID, "TASK_UPDATED", map[string]interface{}{
|
||||
h.broadcaster.BroadcastOnly(payload.WorkspaceID, string(events.EventTaskUpdated), map[string]interface{}{
|
||||
"current_task": payload.CurrentTask,
|
||||
"active_tasks": payload.ActiveTasks,
|
||||
})
|
||||
@@ -593,7 +593,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
|
||||
// so per-heartbeat cost is one in-memory channel send per active
|
||||
// SSE subscriber and one WS hub fan-out. At 30s heartbeat cadence
|
||||
// this is far below any noise floor on either path.
|
||||
h.broadcaster.BroadcastOnly(payload.WorkspaceID, "WORKSPACE_HEARTBEAT", map[string]interface{}{
|
||||
h.broadcaster.BroadcastOnly(payload.WorkspaceID, string(events.EventWorkspaceHeartbeat), map[string]interface{}{
|
||||
"active_tasks": payload.ActiveTasks,
|
||||
"uptime_seconds": payload.UptimeSeconds,
|
||||
})
|
||||
@@ -678,7 +678,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
if err != nil {
|
||||
log.Printf("Heartbeat: failed to mark %s degraded (wedged): %v", payload.WorkspaceID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_DEGRADED", payload.WorkspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
|
||||
"runtime_state": "wedged",
|
||||
"sample_error": payload.SampleError,
|
||||
})
|
||||
@@ -699,7 +699,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
if _, err := db.DB.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, "WORKSPACE_DEGRADED", payload.WorkspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
|
||||
"error_rate": payload.ErrorRate,
|
||||
"sample_error": payload.SampleError,
|
||||
})
|
||||
@@ -718,7 +718,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
if _, err := db.DB.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, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{})
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{})
|
||||
}
|
||||
|
||||
// Recovery: if workspace was offline but is now sending heartbeats, bring it back online.
|
||||
@@ -728,7 +728,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
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 {
|
||||
log.Printf("Heartbeat: failed to recover %s from offline: %v", payload.WorkspaceID, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{})
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{})
|
||||
}
|
||||
|
||||
// Auto-recovery: if a workspace is marked "provisioning" but is actively sending
|
||||
@@ -743,7 +743,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
} else {
|
||||
log.Printf("Heartbeat: transitioned %s from provisioning to online (heartbeat received)", payload.WorkspaceID)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{
|
||||
"recovered_from": currentStatus,
|
||||
})
|
||||
}
|
||||
@@ -771,7 +771,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
|
||||
} else {
|
||||
log.Printf("Heartbeat: transitioned %s from awaiting_agent to online (heartbeat received)", payload.WorkspaceID)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{
|
||||
"recovered_from": currentStatus,
|
||||
})
|
||||
}
|
||||
@@ -820,7 +820,7 @@ func (h *RegistryHandler) UpdateCard(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(c.Request.Context(), "AGENT_CARD_UPDATED", payload.WorkspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(c.Request.Context(), string(events.EventAgentCardUpdated), payload.WorkspaceID, map[string]interface{}{
|
||||
"agent_card": payload.AgentCard,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
package handlers
|
||||
|
||||
// template_files_eic.go — SSH-backed file write for SaaS workspaces
|
||||
// (EC2-per-workspace). Pairs with the existing Docker-path in templates.go
|
||||
// (WriteFile) and template_import.go (ReplaceFiles).
|
||||
// template_files_eic.go — SSH-backed file operations for SaaS workspaces
|
||||
// (EC2-per-workspace). Pairs with the local-Docker path in templates.go
|
||||
// (List/Read/Write/Delete) and template_import.go (ReplaceFiles).
|
||||
//
|
||||
// Flow for a single file write:
|
||||
// 1. Generate ephemeral ed25519 keypair (on-disk for ≤ write duration).
|
||||
// 2. Push the public key via `aws ec2-instance-connect send-ssh-public-key`
|
||||
// so the target sshd accepts it for the next 60s.
|
||||
// 3. Open a TLS-tunnelled TCP port via `aws ec2-instance-connect open-tunnel`
|
||||
// from a local free port → workspace's sshd on 22.
|
||||
// 4. Pipe content to `ssh ... "install -D -m 0644 /dev/stdin <abs path>"`.
|
||||
// `install -D` creates any missing parent dirs atomically. File is owned
|
||||
// by whichever $OSUser we authenticated as (ubuntu by default).
|
||||
// 5. Close tunnel + wipe keydir.
|
||||
// Architecture note: every operation goes through `withEICTunnel`, which
|
||||
// owns the ephemeral-keypair → key-push → tunnel → port-wait dance. Per-
|
||||
// op helpers (list/read/write/delete) only carry the remote command +
|
||||
// stdin/stdout shape. This keeps the EIC connection logic in one place
|
||||
// so a fix to the dance — e.g. PR #2822's `LogLevel=ERROR` shim — only
|
||||
// touches one helper.
|
||||
//
|
||||
// All the AWS calls + ssh tunnel exec go through the same package-level
|
||||
// func vars defined in terminal.go (openTunnelCmd, sendSSHPublicKey) so
|
||||
// tests can stub them the same way the terminal tests do.
|
||||
// Path translation rules: see resolveWorkspaceFilePath. `/configs`
|
||||
// is the per-runtime managed-config indirection (claude-code → /configs,
|
||||
// hermes → /home/ubuntu/.hermes); other allow-listed roots (`/home`,
|
||||
// `/workspace`, `/plugins`) pass through literally.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -32,8 +29,7 @@ import (
|
||||
)
|
||||
|
||||
// workspaceFilePathPrefix maps a runtime name to the absolute base path on
|
||||
// the workspace EC2 where the Files API's relative paths land. New runtimes
|
||||
// can be added here without touching handler code.
|
||||
// the workspace EC2 where the runtime's managed-config dir lives.
|
||||
//
|
||||
// Keep these stable — changing the base path for an existing runtime
|
||||
// without a migration shim will make previously-saved files disappear from
|
||||
@@ -60,41 +56,104 @@ var workspaceFilePathPrefix = map[string]string{
|
||||
// those runtimes actually have on disk.
|
||||
}
|
||||
|
||||
func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
|
||||
// resolveWorkspaceFilePath translates (runtime, root, relPath) into an
|
||||
// absolute path on the workspace EC2.
|
||||
//
|
||||
// `root="/configs"` (or empty / unrecognized) is treated as the
|
||||
// runtime's MANAGED-config dir via workspaceFilePathPrefix —
|
||||
// /home/ubuntu/.hermes for hermes, /configs for claude-code, etc.
|
||||
// This preserves the v1 ReadFile/WriteFile behavior where the canvas's
|
||||
// Config tab GETs/PUTs "config.yaml" without specifying a root and
|
||||
// lands in the runtime's own config dir, even though that dir's
|
||||
// absolute path differs per runtime.
|
||||
//
|
||||
// Any other allow-listed root (`/home`, `/workspace`, `/plugins`) is
|
||||
// treated as a LITERAL absolute path on the EC2 host. Those roots are
|
||||
// universal Linux paths that don't need per-runtime indirection.
|
||||
//
|
||||
// Restricting the literal pass-through to allowedRoots is the
|
||||
// security boundary — the handler also gates this same set, so the
|
||||
// resolver is defence-in-depth: even if a future caller forgets the
|
||||
// handler-side check, the resolver won't translate `?root=/etc` into
|
||||
// a real absolute path.
|
||||
//
|
||||
// relPath is sanitised by validateRelPath (no absolute, no `..`).
|
||||
func resolveWorkspaceFilePath(runtime, root, relPath string) (string, error) {
|
||||
if err := validateRelPath(relPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
|
||||
if !ok {
|
||||
base = "/configs"
|
||||
}
|
||||
base := resolveWorkspaceRootPath(runtime, root)
|
||||
return filepath.Join(base, filepath.Clean(relPath)), nil
|
||||
}
|
||||
|
||||
// eicFileWriteTimeout bounds the whole dance. Key push is <500ms, tunnel
|
||||
// is 1-2s, ssh + write is <2s. 30s gives headroom for slow pulls without
|
||||
// hanging the Files API forever under EIC misconfiguration.
|
||||
const eicFileWriteTimeout = 30 * time.Second
|
||||
|
||||
// writeFileViaEIC writes a single file to the workspace EC2 at the
|
||||
// absolute path that resolveWorkspaceFilePath computed. On success,
|
||||
// optionally invokes the runtime's reload hook (not implemented yet —
|
||||
// tracked as follow-up; for today the canvas issues a separate Restart
|
||||
// after Save).
|
||||
// resolveWorkspaceRootPath returns the absolute base directory on the
|
||||
// workspace EC2 for a given (runtime, root) pair, without touching a
|
||||
// relative file path. Used by listFilesViaEIC to compute the directory
|
||||
// to walk; resolveWorkspaceFilePath joins this with relPath.
|
||||
//
|
||||
// instanceID: AWS EC2 instance id from workspaces.instance_id.
|
||||
// runtime: used only for path-prefix resolution.
|
||||
// relPath: the relative path the caller validated (no /, no ..).
|
||||
// content: file body bytes.
|
||||
func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, content []byte) error {
|
||||
// Centralising the runtime-vs-literal indirection here means
|
||||
// list/read/write/delete agree on what `?root=/configs` means for
|
||||
// hermes vs claude-code vs an unknown runtime — otherwise list could
|
||||
// show one directory while read/write target another.
|
||||
func resolveWorkspaceRootPath(runtime, root string) string {
|
||||
root = strings.TrimSpace(root)
|
||||
// "/configs" + empty + unrecognized → runtime's managed-config dir.
|
||||
// The runtime prefix map is the SSOT for that translation.
|
||||
if root == "" || root == "/configs" || !allowedRoots[root] {
|
||||
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
|
||||
if !ok {
|
||||
base = "/configs"
|
||||
}
|
||||
return base
|
||||
}
|
||||
// Literal universal path (`/home`, `/workspace`, `/plugins`).
|
||||
return root
|
||||
}
|
||||
|
||||
// eicFileOpTimeout bounds the whole tunnel + ssh dance. Key push is
|
||||
// <500ms, tunnel is 1-2s, ssh + remote command is <2s for read/write.
|
||||
// 30s gives headroom for slow EIC pulls + the larger `find` walk that
|
||||
// listFilesViaEIC issues, without hanging the Files API forever under
|
||||
// EIC misconfiguration.
|
||||
const eicFileOpTimeout = 30 * time.Second
|
||||
|
||||
// eicFileOpTimeout was historically named eicFileWriteTimeout when the
|
||||
// only EIC op was writeFile. Keep an alias so any external test that
|
||||
// pinned the old name still compiles; rename can land as a follow-up
|
||||
// once we've gone a release without the alias being touched.
|
||||
//
|
||||
//nolint:revive // intentional alias for back-compat with prior tests.
|
||||
const eicFileWriteTimeout = eicFileOpTimeout
|
||||
|
||||
// eicSSHSession describes an open EIC tunnel ready for an ssh subprocess.
|
||||
// Only valid inside the closure passed to withEICTunnel — the underlying
|
||||
// keypair + tunnel are torn down when the closure returns.
|
||||
type eicSSHSession struct {
|
||||
keyPath string
|
||||
localPort int
|
||||
osUser string
|
||||
instanceID string
|
||||
}
|
||||
|
||||
// withEICTunnel sets up an EIC SSH session (ephemeral keypair → push
|
||||
// → AWS open-tunnel → wait-for-port), invokes fn with a session handle,
|
||||
// and tears everything down on return. The caller is responsible for
|
||||
// applying the per-op context.WithTimeout before calling — this helper
|
||||
// only owns the EIC dance, not the operation budget, so a caller that
|
||||
// needs a different timeout (e.g. a large bulk import) doesn't have to
|
||||
// fight a hard-coded one.
|
||||
//
|
||||
// All AWS calls go through the package-level func vars in terminal.go
|
||||
// (sendSSHPublicKey, openTunnelCmd) so tests can stub them the same way
|
||||
// terminal_test.go does. The whole helper is also assigned to a
|
||||
// `var` (`withEICTunnel`) so handler-dispatch tests can stub the entire
|
||||
// dance instead of having to wire up a fake tunnel + fake ssh server.
|
||||
var withEICTunnel = realWithEICTunnel
|
||||
|
||||
func realWithEICTunnel(ctx context.Context, instanceID string, fn func(s eicSSHSession) error) error {
|
||||
if instanceID == "" {
|
||||
return fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
|
||||
}
|
||||
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
|
||||
if osUser == "" {
|
||||
osUser = "ubuntu"
|
||||
@@ -104,11 +163,7 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
|
||||
region = "us-east-2"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Ephemeral keypair.
|
||||
keyDir, err := os.MkdirTemp("", "molecule-filewrite-*")
|
||||
keyDir, err := os.MkdirTemp("", "molecule-eic-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("keydir mkdir: %w", err)
|
||||
}
|
||||
@@ -116,7 +171,7 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
|
||||
keyPath := keyDir + "/id"
|
||||
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
|
||||
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
|
||||
"-C", "molecule-filewrite",
|
||||
"-C", "molecule-eic",
|
||||
).CombinedOutput(); kerr != nil {
|
||||
return fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
@@ -125,24 +180,21 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
|
||||
return fmt.Errorf("read pubkey: %w", err)
|
||||
}
|
||||
|
||||
// 1. Push key.
|
||||
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
|
||||
return fmt.Errorf("send-ssh-public-key: %w", err)
|
||||
}
|
||||
|
||||
// 2. Open tunnel on an OS-picked free port.
|
||||
localPort, err := pickFreePort()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pick free port: %w", err)
|
||||
}
|
||||
opts := eicSSHOptions{
|
||||
tunnel := openTunnelCmd(eicSSHOptions{
|
||||
InstanceID: instanceID,
|
||||
OSUser: osUser,
|
||||
Region: region,
|
||||
LocalPort: localPort,
|
||||
PrivateKeyPath: keyPath,
|
||||
}
|
||||
tunnel := openTunnelCmd(opts)
|
||||
})
|
||||
tunnel.Env = os.Environ()
|
||||
if err := tunnel.Start(); err != nil {
|
||||
return fmt.Errorf("open-tunnel start: %w", err)
|
||||
@@ -157,183 +209,330 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
|
||||
return fmt.Errorf("tunnel never listened: %w", err)
|
||||
}
|
||||
|
||||
// 3. SSH + install -D. `install` creates any missing parent dirs and
|
||||
// writes the file atomically via temp-file-rename. Permissions 0644
|
||||
// match the existing tar-unpack defaults on the Docker path.
|
||||
//
|
||||
// `sudo -n` (non-interactive) prefix: the canonical containerized
|
||||
// workspace layout puts /configs at the root, owned by root because
|
||||
// cloud-init runs as root (see
|
||||
// molecule-controlplane/internal/provisioner/userdata_containerized.go).
|
||||
// SSH-as-ubuntu can't write into /configs without escalation.
|
||||
// Ubuntu has passwordless sudo on EC2 by default; sudo -n fails fast
|
||||
// (no prompt) if that ever changes, surfacing a clean error instead
|
||||
// of a hang. The hermes path /home/ubuntu/.hermes is ubuntu-owned
|
||||
// and doesn't strictly need sudo, but using it uniformly avoids
|
||||
// per-runtime branching here.
|
||||
//
|
||||
// The remote command is fully deterministic — no user-controlled
|
||||
// input reaches a shell eval (absPath is built from a map + Clean()).
|
||||
sshArgs := []string{
|
||||
"-i", keyPath,
|
||||
return fn(eicSSHSession{
|
||||
keyPath: keyPath,
|
||||
localPort: localPort,
|
||||
osUser: osUser,
|
||||
instanceID: instanceID,
|
||||
})
|
||||
}
|
||||
|
||||
// sshArgs returns the standard ssh CLI args for an EIC session pointed
|
||||
// at the local tunnel port + a single remote command string.
|
||||
//
|
||||
// `LogLevel=ERROR` silences the benign "Warning: Permanently added
|
||||
// '[127.0.0.1]:NNNNN' to known hosts" notice that ssh emits on every
|
||||
// fresh tunnel connection. Without this, the notice lands on stderr
|
||||
// and fools the read/list "empty stdout + empty stderr → not found"
|
||||
// classifiers into thinking the warning is a real ssh-layer error → 500
|
||||
// instead of 404 (Hermes config.yaml load, hongming tenant, 2026-05-05
|
||||
// 02:38; PR #2822). Real auth/tunnel errors stay visible because they're
|
||||
// emitted at ERROR level.
|
||||
//
|
||||
// Originally each helper assembled its own ssh args inline, so PR #2822's
|
||||
// LogLevel=ERROR fix had to be applied to every copy. Centralising here
|
||||
// means future ssh-option tweaks only land in one place.
|
||||
func (s eicSSHSession) sshArgs(remoteCommand string) []string {
|
||||
return []string{
|
||||
"-i", s.keyPath,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
// LogLevel=ERROR silences the benign "Warning: Permanently
|
||||
// added '[127.0.0.1]:NNNNN' to known hosts" notice that ssh
|
||||
// emits on every fresh tunnel connection. Without this, the
|
||||
// notice lands on stderr and fools readFileViaEIC's "empty
|
||||
// stdout + empty stderr → file not found" classifier into
|
||||
// thinking the warning is a real ssh-layer error → 500
|
||||
// instead of 404 (Hermes config.yaml load, hongming tenant,
|
||||
// 2026-05-05 02:38). Real auth/tunnel errors stay visible
|
||||
// because they're emitted at ERROR level.
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-p", fmt.Sprintf("%d", localPort),
|
||||
fmt.Sprintf("%s@127.0.0.1", osUser),
|
||||
fmt.Sprintf("sudo -n install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
|
||||
"-p", fmt.Sprintf("%d", s.localPort),
|
||||
fmt.Sprintf("%s@127.0.0.1", s.osUser),
|
||||
remoteCommand,
|
||||
}
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
||||
sshCmd.Env = os.Environ()
|
||||
sshCmd.Stdin = bytes.NewReader(content)
|
||||
var stderr bytes.Buffer
|
||||
sshCmd.Stderr = &stderr
|
||||
if err := sshCmd.Run(); err != nil {
|
||||
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
// buildInstallShell returns the remote command for atomically writing
|
||||
// `/dev/stdin` to absPath with mode 0644 via `sudo -n install -D`.
|
||||
// `install -D` creates any missing parent dirs and writes via
|
||||
// temp-file-rename (atomic). Pure function for direct testability —
|
||||
// the only variable input (absPath) is shellQuote-wrapped to defeat
|
||||
// any shell metachar in a future caller's path.
|
||||
func buildInstallShell(absPath string) string {
|
||||
return fmt.Sprintf("sudo -n install -D -m 0644 /dev/stdin %s", shellQuote(absPath))
|
||||
}
|
||||
|
||||
// buildCatShell returns the remote command for reading absPath and
|
||||
// swallowing missing-file stderr (so the empty-stdout + non-zero-exit
|
||||
// case is unambiguous → os.ErrNotExist at the caller).
|
||||
func buildCatShell(absPath string) string {
|
||||
return fmt.Sprintf("sudo -n cat %s 2>/dev/null", shellQuote(absPath))
|
||||
}
|
||||
|
||||
// buildRmShell returns the remote command for `sudo -n rm -f` against
|
||||
// absPath. `-f` (not `-rf`) is intentional — directory removal needs
|
||||
// its own explicit endpoint if/when the canvas grows that affordance,
|
||||
// and `-rf` would let a misclassified directory entry trigger a
|
||||
// recursive delete.
|
||||
func buildRmShell(absPath string) string {
|
||||
return fmt.Sprintf("sudo -n rm -f %s", shellQuote(absPath))
|
||||
}
|
||||
|
||||
// buildFindShell returns the remote command for enumerating files
|
||||
// under listPath up to maxDepth, emitting `TYPE|SIZE|REL_PATH` lines
|
||||
// (matches the local-Docker container path's parser exactly).
|
||||
//
|
||||
// `2>/dev/null` swallows find's "No such file" error so a missing
|
||||
// listing root surfaces as empty stdout (handler returns []) rather
|
||||
// than 500.
|
||||
//
|
||||
// `stat -c %s` is GNU coreutils; `stat -f %z` is BSD. Try GNU first,
|
||||
// fall back to BSD, then 0 — same shape the local-Docker `sh -c`
|
||||
// version uses so a future cross-runtime fleet (Alpine vs Ubuntu)
|
||||
// doesn't regress.
|
||||
//
|
||||
// Hidden / cache dir pruning matches the container path: .git,
|
||||
// __pycache__, node_modules, .DS_Store. Without these the tree drowns
|
||||
// in transient artefacts on a /workspace listing.
|
||||
func buildFindShell(listPath string, maxDepth int) string {
|
||||
return fmt.Sprintf(
|
||||
`sudo -n find %s -maxdepth %d -not -path '*/.git/*' -not -path '*/__pycache__/*' -not -path '*/node_modules/*' -not -name .DS_Store 2>/dev/null | while IFS= read -r f; do `+
|
||||
`rel="${f#%s/}"; [ "$rel" = %s ] && continue; [ -z "$rel" ] && continue; `+
|
||||
`if [ -d "$f" ]; then echo "d|0|$rel"; else `+
|
||||
`s=$(stat -c %%s "$f" 2>/dev/null || stat -f %%z "$f" 2>/dev/null || echo 0); echo "f|$s|$rel"; `+
|
||||
`fi; done`,
|
||||
shellQuote(listPath), maxDepth, shellQuote(listPath), shellQuote(listPath),
|
||||
)
|
||||
}
|
||||
|
||||
// parseFindOutput parses TYPE|SIZE|REL_PATH lines emitted by
|
||||
// buildFindShell into eicFileEntry rows. Whitespace-only lines and
|
||||
// malformed rows are silently skipped — the same behaviour as the
|
||||
// local-Docker container parser for symmetric output.
|
||||
func parseFindOutput(raw []byte) []eicFileEntry {
|
||||
files := make([]eicFileEntry, 0)
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
parts := strings.SplitN(line, "|", 3)
|
||||
if len(parts) != 3 || parts[2] == "" {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
fmt.Sscanf(parts[1], "%d", &size)
|
||||
files = append(files, eicFileEntry{
|
||||
Path: parts[2],
|
||||
Size: size,
|
||||
Dir: parts[0] == "d",
|
||||
})
|
||||
}
|
||||
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s wrote %d bytes → %s",
|
||||
instanceID, runtime, len(content), absPath)
|
||||
return nil
|
||||
return files
|
||||
}
|
||||
|
||||
// shellQuote wraps a value in single quotes + escapes embedded single
|
||||
// quotes for POSIX sh. Used for the sole piece of variable data in the
|
||||
// remote ssh command. (absPath is already built from a map + Clean() so
|
||||
// traversal is blocked regardless; this is defence-in-depth against
|
||||
// future refactor that might accept user paths here.)
|
||||
// quotes for POSIX sh. Used for the variable parts of remote ssh
|
||||
// commands (absolute paths). The paths are already built from a
|
||||
// validated allowlist + Clean(), so traversal is blocked regardless;
|
||||
// this is defence-in-depth against a future refactor that might accept
|
||||
// user paths directly here.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// writeFileViaEIC writes a single file to the workspace EC2 at the
|
||||
// absolute path that resolveWorkspaceFilePath computed. On success,
|
||||
// optionally invokes the runtime's reload hook (not implemented yet —
|
||||
// tracked as follow-up; for today the canvas issues a separate Restart
|
||||
// after Save).
|
||||
//
|
||||
// `install -D` creates any missing parent dirs and writes atomically
|
||||
// via temp-file-rename. Permissions 0644 match the existing tar-unpack
|
||||
// defaults on the Docker path.
|
||||
//
|
||||
// `sudo -n` (non-interactive) prefix: the canonical containerized
|
||||
// workspace layout puts /configs at the root, owned by root because
|
||||
// cloud-init runs as root (see
|
||||
// molecule-controlplane/internal/provisioner/userdata_containerized.go).
|
||||
// SSH-as-ubuntu can't write into /configs without escalation. Ubuntu
|
||||
// has passwordless sudo on EC2 by default; sudo -n fails fast (no
|
||||
// prompt) if that ever changes, surfacing a clean error instead of a
|
||||
// hang. The hermes path /home/ubuntu/.hermes is ubuntu-owned and
|
||||
// doesn't strictly need sudo, but using it uniformly avoids per-runtime
|
||||
// branching here.
|
||||
func writeFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath string, content []byte) error {
|
||||
absPath, err := resolveWorkspaceFilePath(runtime, root, relPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
|
||||
defer cancel()
|
||||
|
||||
return withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildInstallShell(absPath))...)
|
||||
sshCmd.Env = os.Environ()
|
||||
sshCmd.Stdin = bytes.NewReader(content)
|
||||
var stderr bytes.Buffer
|
||||
sshCmd.Stderr = &stderr
|
||||
if err := sshCmd.Run(); err != nil {
|
||||
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s root=%s wrote %d bytes → %s",
|
||||
instanceID, runtime, root, len(content), absPath)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// readFileViaEIC reads a single file from the workspace EC2 at the
|
||||
// absolute path that resolveWorkspaceFilePath computes. Mirrors
|
||||
// writeFileViaEIC end-to-end (ephemeral keypair, EIC tunnel, ssh) so
|
||||
// canvas's Config tab can GET back what it just PUT. Pre-fix the GET
|
||||
// path (templates.go ReadFile) only handled local Docker containers
|
||||
// + a host-side template fallback; SaaS workspaces (EC2-per-workspace)
|
||||
// always 404'd because neither handles their on-EC2 layout.
|
||||
// writeFileViaEIC (ephemeral keypair, EIC tunnel, ssh) so the canvas's
|
||||
// Config tab can GET back what it just PUT.
|
||||
//
|
||||
// Returns ("", os.ErrNotExist) when the remote path doesn't exist so
|
||||
// the handler can map it to HTTP 404 cleanly. Other errors propagate.
|
||||
func readFileViaEIC(ctx context.Context, instanceID, runtime, relPath string) ([]byte, error) {
|
||||
if instanceID == "" {
|
||||
return nil, fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
|
||||
}
|
||||
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
|
||||
//
|
||||
// `sudo -n cat`: /configs is root-owned (same reason writeFileViaEIC
|
||||
// needs sudo). The path is built from a validated map + Clean(), so no
|
||||
// user-controlled string reaches the shell here. `2>/dev/null` swallows
|
||||
// `cat: ...: No such file` so the missing-file case returns empty
|
||||
// stdout + non-zero exit, which we translate to os.ErrNotExist.
|
||||
func readFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath string) ([]byte, error) {
|
||||
absPath, err := resolveWorkspaceFilePath(runtime, root, relPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
|
||||
if osUser == "" {
|
||||
osUser = "ubuntu"
|
||||
}
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if region == "" {
|
||||
region = "us-east-2"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
|
||||
defer cancel()
|
||||
|
||||
keyDir, err := os.MkdirTemp("", "molecule-fileread-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keydir mkdir: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(keyDir) }()
|
||||
keyPath := keyDir + "/id"
|
||||
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
|
||||
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
|
||||
"-C", "molecule-fileread",
|
||||
).CombinedOutput(); kerr != nil {
|
||||
return nil, fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
pubKey, err := os.ReadFile(keyPath + ".pub")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pubkey: %w", err)
|
||||
}
|
||||
|
||||
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
|
||||
return nil, fmt.Errorf("send-ssh-public-key: %w", err)
|
||||
}
|
||||
|
||||
localPort, err := pickFreePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pick free port: %w", err)
|
||||
}
|
||||
tunnel := openTunnelCmd(eicSSHOptions{
|
||||
InstanceID: instanceID,
|
||||
OSUser: osUser,
|
||||
Region: region,
|
||||
LocalPort: localPort,
|
||||
PrivateKeyPath: keyPath,
|
||||
var out []byte
|
||||
runErr := withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildCatShell(absPath))...)
|
||||
sshCmd.Env = os.Environ()
|
||||
var stdout, stderr bytes.Buffer
|
||||
sshCmd.Stdout = &stdout
|
||||
sshCmd.Stderr = &stderr
|
||||
err := sshCmd.Run()
|
||||
out = stdout.Bytes()
|
||||
if err != nil {
|
||||
// `cat` returns 1 on missing file; with 2>/dev/null we have no
|
||||
// stderr distinguisher. Treat empty-stdout + empty-stderr +
|
||||
// non-zero exit as not-found rather than a tunnel/auth error
|
||||
// (those usually produce stderr from ssh itself, not from the
|
||||
// remote command).
|
||||
if len(out) == 0 && stderr.Len() == 0 {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return fmt.Errorf("ssh cat: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
log.Printf("readFileViaEIC: ws instance=%s runtime=%s root=%s read %d bytes ← %s",
|
||||
instanceID, runtime, root, len(out), absPath)
|
||||
return nil
|
||||
})
|
||||
tunnel.Env = os.Environ()
|
||||
if err := tunnel.Start(); err != nil {
|
||||
return nil, fmt.Errorf("open-tunnel start: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if tunnel.Process != nil {
|
||||
_ = tunnel.Process.Kill()
|
||||
}
|
||||
_ = tunnel.Wait()
|
||||
}()
|
||||
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
|
||||
return nil, fmt.Errorf("tunnel never listened: %w", err)
|
||||
}
|
||||
|
||||
// `sudo -n cat`: /configs is root-owned by cloud-init (same reason
|
||||
// writeFileViaEIC needs sudo to install). The path is built from a
|
||||
// validated map + Clean(), so no user-controlled string reaches the
|
||||
// shell here. `2>/dev/null` swallows `cat: ...: No such file` so
|
||||
// the missing-file case returns empty stdout + non-zero exit, which
|
||||
// we translate to os.ErrNotExist below.
|
||||
sshCmd := exec.CommandContext(ctx, "ssh",
|
||||
"-i", keyPath,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
// LogLevel=ERROR silences the benign "Warning: Permanently
|
||||
// added '[127.0.0.1]:NNNNN' to known hosts" notice that ssh
|
||||
// emits on every fresh tunnel connection. Without this, the
|
||||
// notice lands on stderr and fools readFileViaEIC's "empty
|
||||
// stdout + empty stderr → file not found" classifier into
|
||||
// thinking the warning is a real ssh-layer error → 500
|
||||
// instead of 404 (Hermes config.yaml load, hongming tenant,
|
||||
// 2026-05-05 02:38). Real auth/tunnel errors stay visible
|
||||
// because they're emitted at ERROR level.
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-p", fmt.Sprintf("%d", localPort),
|
||||
fmt.Sprintf("%s@127.0.0.1", osUser),
|
||||
fmt.Sprintf("sudo -n cat %s 2>/dev/null", shellQuote(absPath)),
|
||||
)
|
||||
sshCmd.Env = os.Environ()
|
||||
var stdout, stderr bytes.Buffer
|
||||
sshCmd.Stdout = &stdout
|
||||
sshCmd.Stderr = &stderr
|
||||
runErr := sshCmd.Run()
|
||||
out := stdout.Bytes()
|
||||
if runErr != nil {
|
||||
// `cat` returns 1 on missing file; with 2>/dev/null we have no
|
||||
// stderr distinguisher. Treat empty-stdout + non-zero exit as
|
||||
// not-found rather than a tunnel/auth error (those usually
|
||||
// produce stderr from ssh itself, not from the remote command).
|
||||
if len(out) == 0 && stderr.Len() == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, fmt.Errorf("ssh cat: %w (%s)", runErr, strings.TrimSpace(stderr.String()))
|
||||
return nil, runErr
|
||||
}
|
||||
log.Printf("readFileViaEIC: ws instance=%s runtime=%s read %d bytes ← %s",
|
||||
instanceID, runtime, len(out), absPath)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// eicFileEntry is the wire shape returned by listFilesViaEIC. It
|
||||
// matches the inline `fileEntry` in templates.go::ListFiles so the
|
||||
// handler can emit either path's output without a translation layer.
|
||||
type eicFileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Dir bool `json:"dir"`
|
||||
}
|
||||
|
||||
// listFilesViaEIC enumerates files under <root>/<sub> on the workspace
|
||||
// EC2 host, up to the given depth, returning entries with paths
|
||||
// relative to the listing root (matching the local-Docker path's
|
||||
// output). Closes the symmetry gap that left ListFiles silently
|
||||
// returning [] for SaaS workspaces — see issue #2999.
|
||||
//
|
||||
// Output line format: TYPE|SIZE|REL_PATH (matches the container's find
|
||||
// shell so the parser is identical). `find -maxdepth N` traverses up
|
||||
// to N levels; the canvas requests depth=1 by default and re-fetches
|
||||
// when the user expands a directory.
|
||||
//
|
||||
// Pruning: same hidden / cache dirs as the container path (.git,
|
||||
// __pycache__, node_modules, .DS_Store) so the canvas's tree doesn't
|
||||
// drown in transient artefacts.
|
||||
//
|
||||
// `sudo -n` matches the read/write paths — even though the universal
|
||||
// roots (/home, /workspace, /plugins) are typically ubuntu-owned and
|
||||
// don't need it, /configs and runtime-prefix dirs do (root-owned by
|
||||
// cloud-init), and using sudo uniformly avoids per-root branching.
|
||||
func listFilesViaEIC(ctx context.Context, instanceID, runtime, root, sub string, depth int) ([]eicFileEntry, error) {
|
||||
if sub != "" {
|
||||
if err := validateRelPath(sub); err != nil {
|
||||
return nil, fmt.Errorf("invalid sub: %w", err)
|
||||
}
|
||||
}
|
||||
if depth < 1 {
|
||||
depth = 1
|
||||
}
|
||||
if depth > 5 {
|
||||
depth = 5
|
||||
}
|
||||
listPath := resolveWorkspaceRootPath(runtime, root)
|
||||
if sub != "" {
|
||||
listPath = filepath.Join(listPath, filepath.Clean(sub))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
|
||||
defer cancel()
|
||||
|
||||
var rawOutput []byte
|
||||
runErr := withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildFindShell(listPath, depth))...)
|
||||
sshCmd.Env = os.Environ()
|
||||
var stdout, stderr bytes.Buffer
|
||||
sshCmd.Stdout = &stdout
|
||||
sshCmd.Stderr = &stderr
|
||||
if err := sshCmd.Run(); err != nil {
|
||||
// Empty stdout + empty stderr after we swallowed find's
|
||||
// own error stream means the listing root genuinely
|
||||
// doesn't exist on this workspace — return an empty
|
||||
// slice rather than a 500. Real ssh/tunnel errors emit
|
||||
// to stderr at LogLevel=ERROR.
|
||||
if stdout.Len() == 0 && stderr.Len() == 0 {
|
||||
rawOutput = nil
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ssh find: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
rawOutput = stdout.Bytes()
|
||||
return nil
|
||||
})
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
files := parseFindOutput(rawOutput)
|
||||
log.Printf("listFilesViaEIC: ws instance=%s runtime=%s root=%s sub=%s depth=%d → %d entries from %s",
|
||||
instanceID, runtime, root, sub, depth, len(files), listPath)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// deleteFileViaEIC removes a single file from the workspace EC2.
|
||||
// Returns nil for both "deleted" and "didn't exist" — `rm -f` doesn't
|
||||
// distinguish, and the canvas's delete-then-refresh flow doesn't need
|
||||
// it to.
|
||||
//
|
||||
// Symmetry note: pre-fix DeleteFile (templates.go:514) had no EIC
|
||||
// branch, so right-click delete on a SaaS workspace would fall through
|
||||
// to the local-Docker path, find no container (dockerCli is nil on
|
||||
// SaaS), and try the ephemeral-volume path which itself only handles
|
||||
// local Docker volumes. Net effect: silent no-op. Closing this gap is
|
||||
// part of issue #2999.
|
||||
func deleteFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath string) error {
|
||||
absPath, err := resolveWorkspaceFilePath(runtime, root, relPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
|
||||
defer cancel()
|
||||
|
||||
return withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
|
||||
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildRmShell(absPath))...)
|
||||
sshCmd.Env = os.Environ()
|
||||
var stderr bytes.Buffer
|
||||
sshCmd.Stderr = &stderr
|
||||
if err := sshCmd.Run(); err != nil {
|
||||
return fmt.Errorf("ssh rm: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
log.Printf("deleteFileViaEIC: ws instance=%s runtime=%s root=%s removed %s",
|
||||
instanceID, runtime, root, absPath)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package handlers
|
||||
|
||||
// template_files_eic_dispatch_test.go — handler-level tests for the
|
||||
// EIC dispatch added in PR-A of issue #2999. Pre-PR-A, ListFiles and
|
||||
// DeleteFile silently fell through to the local-Docker path on SaaS
|
||||
// workspaces (where dockerCli is nil) and returned [] / silent no-op.
|
||||
// These tests pin the new behavior:
|
||||
//
|
||||
// 1. instance_id != "" → handler invokes the EIC helper
|
||||
// 2. EIC success → 200 with the helper's payload
|
||||
// 3. EIC error → 500 (does NOT fall through to local-Docker /
|
||||
// template-dir, which would mask the real failure)
|
||||
// 4. instance_id == "" → existing local-Docker / template-dir
|
||||
// fallback (back-compat with self-hosted operators)
|
||||
//
|
||||
// Stubs `withEICTunnel` so the entire EIC dance (keypair, AWS calls,
|
||||
// tunnel, ssh) is replaced with a fake closure that yields a captured
|
||||
// session — lets the test capture what the inner closure would have
|
||||
// done without spinning up a real sshd. The test for the actual
|
||||
// remote shell shapes lives in template_files_eic_shells_test.go
|
||||
// (pure-function tests on buildFindShell / buildInstallShell etc).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// stubWithEICTunnel replaces the package-level withEICTunnel with a
|
||||
// closure that records its inputs and runs fn against a fake session,
|
||||
// returning fnErr from the inner fn if non-nil. Restores the original
|
||||
// on test cleanup.
|
||||
func stubWithEICTunnel(t *testing.T, fnErr error) (calls *[]string) {
|
||||
t.Helper()
|
||||
captured := []string{}
|
||||
calls = &captured
|
||||
prev := withEICTunnel
|
||||
withEICTunnel = func(ctx context.Context, instanceID string, fn func(s eicSSHSession) error) error {
|
||||
captured = append(captured, instanceID)
|
||||
// Hand the closure a sentinel session so any code that pulls
|
||||
// session fields gets deterministic non-empty values. The
|
||||
// closure's exec.Command call will fail at runtime because no
|
||||
// real ssh exists for instanceID="i-test"; but most
|
||||
// dispatch-tests inject fnErr directly to skip that.
|
||||
return fnErr
|
||||
}
|
||||
t.Cleanup(func() { withEICTunnel = prev })
|
||||
return calls
|
||||
}
|
||||
|
||||
// stubWithEICTunnelReturning is like stubWithEICTunnel but lets the
|
||||
// test substitute the inner fn entirely so it can populate `out` /
|
||||
// return shaped errors without invoking the real ssh closure.
|
||||
func stubWithEICTunnelReturning(t *testing.T, replacement func(s eicSSHSession) error) (calls *[]string) {
|
||||
t.Helper()
|
||||
captured := []string{}
|
||||
calls = &captured
|
||||
prev := withEICTunnel
|
||||
withEICTunnel = func(ctx context.Context, instanceID string, _ func(s eicSSHSession) error) error {
|
||||
captured = append(captured, instanceID)
|
||||
return replacement(eicSSHSession{instanceID: instanceID, osUser: "ubuntu", localPort: 12345, keyPath: "/tmp/k"})
|
||||
}
|
||||
t.Cleanup(func() { withEICTunnel = prev })
|
||||
return calls
|
||||
}
|
||||
|
||||
// ---- ListFiles EIC dispatch ----
|
||||
|
||||
// TestListFiles_EICDispatch_Success: a workspace with instance_id set
|
||||
// must route to listFilesViaEIC, NOT to local-Docker / template-dir.
|
||||
// Verifies the handler hands the EIC helper's output back as JSON.
|
||||
//
|
||||
// Until PR-A this test would fail no matter what mocks were in place —
|
||||
// the dispatch branch did not exist.
|
||||
func TestListFiles_EICDispatch_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-eic").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
|
||||
AddRow("My Agent", "i-test", "claude-code"))
|
||||
|
||||
// The package-level withEICTunnel stub doesn't get to set the
|
||||
// listFilesViaEIC outparam, so we have to override the helper at
|
||||
// a higher level. Instead, we stub withEICTunnel to *return* the
|
||||
// inner closure's err — but we can't reach the byte-output path.
|
||||
// Use the dedicated stubWithEICTunnelReturning + intercept ssh:
|
||||
// since the tunnel stub doesn't run the closure's ssh exec at all
|
||||
// when we replace the inner fn, the helper's `rawOutput` stays
|
||||
// nil and parseFindOutput returns []. Sufficient for "200 + empty"
|
||||
// dispatch verification.
|
||||
stubWithEICTunnelReturning(t, func(s eicSSHSession) error {
|
||||
return nil // skip the real ssh; outer rawOutput stays nil → []
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-eic"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-eic/files?root=/configs", nil)
|
||||
|
||||
(&TemplatesHandler{}).ListFiles(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("response not JSON array: %v (body=%s)", err, w.Body.String())
|
||||
}
|
||||
// EIC stub returned no output → empty list. The point of this
|
||||
// assertion is "200 with [] from EIC", not "fell through to host
|
||||
// template fallback which would 200 with []" — to discriminate,
|
||||
// we ALSO assert mock expectations were met (proving the new SQL
|
||||
// shape was queried) AND the local-Docker fallback path can't
|
||||
// have run (handler.docker is nil here, so findContainer returns
|
||||
// "" and the only paths that reach 200 are EIC or template-dir;
|
||||
// template-dir requires a non-empty configsDir which we left at
|
||||
// "" via the zero-value handler).
|
||||
if got == nil {
|
||||
t.Errorf("expected JSON array (even if empty); got null")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListFiles_EICDispatch_Error: a real EIC failure (network blip,
|
||||
// AWS API throttle, sshd down) must surface as 500, NOT silently fall
|
||||
// through to the local-Docker path which would mask the failure as
|
||||
// "0 files" — which is the exact UX symptom the PR-A bug report cites.
|
||||
func TestListFiles_EICDispatch_Error(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-eic-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
|
||||
AddRow("My Agent", "i-test", "claude-code"))
|
||||
|
||||
stubWithEICTunnel(t, errors.New("eic open-tunnel: timeout"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-eic-err"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-eic-err/files?root=/home", nil)
|
||||
|
||||
(&TemplatesHandler{}).ListFiles(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "failed to list files") {
|
||||
t.Errorf("error body should describe ListFiles failure; got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListFiles_EICBranch_NotTakenForSelfHosted: workspaces with no
|
||||
// instance_id (self-hosted, local-Docker path) MUST NOT enter the EIC
|
||||
// branch. Stubs withEICTunnel to fail loudly if it's called — the
|
||||
// stub being invoked is itself the assertion failure.
|
||||
func TestListFiles_EICBranch_NotTakenForSelfHosted(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-local").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
|
||||
AddRow("Local Agent", "", ""))
|
||||
|
||||
prev := withEICTunnel
|
||||
withEICTunnel = func(ctx context.Context, instanceID string, fn func(s eicSSHSession) error) error {
|
||||
t.Errorf("withEICTunnel called for self-hosted workspace (instance_id=''); EIC branch must be gated on non-empty instance_id")
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
defer func() { withEICTunnel = prev }()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-local"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-local/files", nil)
|
||||
|
||||
(&TemplatesHandler{configsDir: t.TempDir()}).ListFiles(c)
|
||||
|
||||
// Don't pin the response code here — the local path's behavior is
|
||||
// covered by TestListFiles_FallbackToHost_NoTemplate. Just confirm
|
||||
// EIC wasn't called.
|
||||
}
|
||||
|
||||
// ---- DeleteFile EIC dispatch ----
|
||||
|
||||
// TestDeleteFile_EICDispatch_Success: same shape as ListFiles —
|
||||
// instance_id != "" routes to deleteFileViaEIC and returns 200 on
|
||||
// success. Pre-PR-A right-click delete on a SaaS workspace silently
|
||||
// no-op'd because findContainer returned "" and the ephemeral-volume
|
||||
// fallback only handles local Docker volumes.
|
||||
func TestDeleteFile_EICDispatch_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-eic-del").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
|
||||
AddRow("My Agent", "i-test", "claude-code"))
|
||||
|
||||
stubWithEICTunnel(t, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-eic-del"},
|
||||
{Key: "path", Value: "old.txt"},
|
||||
}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-eic-del/files/old.txt", nil)
|
||||
|
||||
(&TemplatesHandler{}).DeleteFile(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"deleted"`) {
|
||||
t.Errorf("expected status:deleted; got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile_EICDispatch_Error(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-eic-del-err").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
|
||||
AddRow("My Agent", "i-test", "hermes"))
|
||||
|
||||
stubWithEICTunnel(t, errors.New("ssh rm: connection refused"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-eic-del-err"},
|
||||
{Key: "path", Value: "old.txt"},
|
||||
}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-eic-del-err/files/old.txt", nil)
|
||||
|
||||
(&TemplatesHandler{}).DeleteFile(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListFiles_RootValidation: the handler must reject roots outside
|
||||
// the allowlist BEFORE any DB query (otherwise a bad root would burn
|
||||
// a tunnel + EIC call to discover what a 400 already knows). Critical
|
||||
// security guard — without it `?root=/etc` would translate via the
|
||||
// resolver's literal-pass-through. Let me prove the gate exists by
|
||||
// driving an out-of-allowlist root and asserting 400 + no DB query.
|
||||
func TestListFiles_RootValidation(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-x"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-x/files?root=/etc", nil)
|
||||
|
||||
(&TemplatesHandler{}).ListFiles(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for /etc root, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFile_RootValidation mirrors the ListFiles guard. PR-A
|
||||
// added ?root= handling to DeleteFile so the canvas's right-click
|
||||
// delete works on any root (not just /configs) — that means the
|
||||
// allowlist guard has to be present here too, otherwise an unsafe
|
||||
// root flows straight into the resolver.
|
||||
func TestDeleteFile_RootValidation(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-x"},
|
||||
{Key: "path", Value: "f.txt"},
|
||||
}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-x/files/f.txt?root=/etc", nil)
|
||||
|
||||
(&TemplatesHandler{}).DeleteFile(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for /etc root, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package handlers
|
||||
|
||||
// template_files_eic_shells_test.go — pure-function tests for the
|
||||
// remote shell builders + parser. Factored out of the EIC helpers so
|
||||
// the wire shape can be pinned without standing up a real EIC tunnel
|
||||
// or sshd. If a future edit changes the find/install/cat/rm shell in
|
||||
// a way that drifts from the local-Docker container path, these tests
|
||||
// catch it before staging.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildInstallShell pins the write-side remote command. `install`
|
||||
// (not `cp`/`tee`) is load-bearing — it creates parent dirs (-D) and
|
||||
// writes atomically via temp-file-rename. Permissions 0644 match the
|
||||
// local-Docker tar-unpack defaults so a save → restart → save → restart
|
||||
// cycle doesn't flip-flop file modes per backend.
|
||||
func TestBuildInstallShell(t *testing.T) {
|
||||
got := buildInstallShell("/configs/config.yaml")
|
||||
wants := []string{
|
||||
"sudo -n", // privilege escalation for root-owned /configs
|
||||
"install -D", // creates parent dirs
|
||||
"-m 0644", // permission contract
|
||||
"/dev/stdin", // pipe-from-ssh source
|
||||
"'/configs/config.yaml'", // shell-quoted destination
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("buildInstallShell missing %q in: %s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCatShell pins the read-side remote command. `2>/dev/null`
|
||||
// is load-bearing: without it the missing-file case emits "cat: ...:
|
||||
// No such file" to stderr, and the helper's "empty stdout + empty
|
||||
// stderr → os.ErrNotExist" classifier fires the wrong branch (500
|
||||
// instead of 404). The tunnel-warning silencer (LogLevel=ERROR in
|
||||
// sshArgs) handles the ssh side; this one handles the remote-cmd side.
|
||||
func TestBuildCatShell(t *testing.T) {
|
||||
got := buildCatShell("/home/ubuntu/.hermes/config.yaml")
|
||||
wants := []string{
|
||||
"sudo -n",
|
||||
"cat",
|
||||
"'/home/ubuntu/.hermes/config.yaml'",
|
||||
"2>/dev/null", // missing-file → empty stdout + non-zero exit
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("buildCatShell missing %q in: %s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRmShell pins `rm -f`, NOT `rm -rf`. A misclassified
|
||||
// directory entry passing through the validator must NOT trigger a
|
||||
// recursive delete. Directory removal needs its own explicit endpoint
|
||||
// when/if the canvas grows that affordance.
|
||||
func TestBuildRmShell(t *testing.T) {
|
||||
got := buildRmShell("/configs/dead.yaml")
|
||||
wants := []string{"sudo -n", "rm -f", "'/configs/dead.yaml'"}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("buildRmShell missing %q in: %s", w, got)
|
||||
}
|
||||
}
|
||||
// Negative assertion: NEVER emit -rf.
|
||||
if strings.Contains(got, "rm -rf") {
|
||||
t.Errorf("buildRmShell uses -rf, must use -f only: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFindShell pins the listing-side remote command — it must
|
||||
// match the local-Docker path's parser shape (TYPE|SIZE|REL_PATH per
|
||||
// line) AND prune the same hidden / cache directories. If either
|
||||
// side drifts, a /workspace listing on EC2 either drowns in node_modules
|
||||
// noise (pruning regression) or drops files entirely (parser shape
|
||||
// regression).
|
||||
func TestBuildFindShell(t *testing.T) {
|
||||
got := buildFindShell("/workspace", 2)
|
||||
wants := []string{
|
||||
"sudo -n find",
|
||||
"'/workspace'",
|
||||
"-maxdepth 2",
|
||||
// Matches local-Docker container path; without these the EC2
|
||||
// listing fills with VCS/build artefacts.
|
||||
"-not -path '*/.git/*'",
|
||||
"-not -path '*/__pycache__/*'",
|
||||
"-not -path '*/node_modules/*'",
|
||||
"-not -name .DS_Store",
|
||||
"2>/dev/null", // missing-root → empty stdout + non-zero exit
|
||||
// Wire shape — emit "TYPE|SIZE|REL_PATH" so parseFindOutput
|
||||
// (and the canvas tree builder) can decode each line.
|
||||
"d|0|",
|
||||
"f|",
|
||||
// Portable stat: GNU first, BSD fallback, then 0.
|
||||
"stat -c %s",
|
||||
"stat -f %z",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("buildFindShell missing %q in: %s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFindShell_DepthForwarding catches a regression where the
|
||||
// helper hard-codes a depth instead of using the caller's value.
|
||||
// `?depth=` on the canvas side controls how many levels expand on
|
||||
// load — losing it means the file tree is either empty (depth=0) or
|
||||
// the network blows up on a top-level /home with everyone's $HOME
|
||||
// (uncapped).
|
||||
func TestBuildFindShell_DepthForwarding(t *testing.T) {
|
||||
for _, d := range []int{1, 3, 5} {
|
||||
got := buildFindShell("/configs", d)
|
||||
want := "-maxdepth " + intToStr(d)
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("buildFindShell depth=%d output missing %q: %s", d, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// intToStr avoids pulling strconv into a one-liner; matches the shell
|
||||
// builder's fmt.Sprintf %d output exactly.
|
||||
func intToStr(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
s := string(buf[i:])
|
||||
if neg {
|
||||
return "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestParseFindOutput pins the parser. Each line is TYPE|SIZE|REL,
|
||||
// blank/short lines silently skipped. Pre-PR-A this logic was inlined
|
||||
// in the handler with the same shape; extracting + testing separately
|
||||
// removes the "regex passes against the inline parser but a future
|
||||
// refactor of the handler subtly changes the parse" failure mode.
|
||||
func TestParseFindOutput(t *testing.T) {
|
||||
in := []byte(`d|0|nested
|
||||
f|123|nested/a.yaml
|
||||
f|45|README.md
|
||||
|
||||
invalid-line
|
||||
f||no-size
|
||||
d|0|
|
||||
`)
|
||||
got := parseFindOutput(in)
|
||||
// Want 4 entries: nested(d), nested/a.yaml(f,123), README.md(f,45),
|
||||
// no-size(f,0). Blank lines, "invalid-line" (no pipes), and
|
||||
// `d|0|` (empty rel) are skipped.
|
||||
wantPaths := []string{"nested", "nested/a.yaml", "README.md", "no-size"}
|
||||
if len(got) != len(wantPaths) {
|
||||
t.Fatalf("got %d entries, want %d: %+v", len(got), len(wantPaths), got)
|
||||
}
|
||||
for i, w := range wantPaths {
|
||||
if got[i].Path != w {
|
||||
t.Errorf("entry[%d].Path = %q, want %q", i, got[i].Path, w)
|
||||
}
|
||||
}
|
||||
if !got[0].Dir {
|
||||
t.Errorf("entry[0] should be Dir")
|
||||
}
|
||||
if got[1].Size != 123 {
|
||||
t.Errorf("entry[1].Size = %d, want 123", got[1].Size)
|
||||
}
|
||||
if got[3].Size != 0 {
|
||||
t.Errorf("entry[3].Size on missing-size line = %d, want 0", got[3].Size)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFindOutput_EmptyInput — a missing listing root yields
|
||||
// empty stdout (find swallows the "No such file" via 2>/dev/null),
|
||||
// which must round-trip to a JSON `[]`, not null. The handler does
|
||||
// `make([]eicFileEntry, 0)` to enforce this; the test pins the
|
||||
// helper-level guarantee independently.
|
||||
func TestParseFindOutput_EmptyInput(t *testing.T) {
|
||||
got := parseFindOutput([]byte(""))
|
||||
if got == nil {
|
||||
t.Errorf("parseFindOutput(\"\") returned nil; want empty slice for JSON []")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("parseFindOutput(\"\") = %+v; want []", got)
|
||||
}
|
||||
}
|
||||
@@ -7,39 +7,112 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResolveWorkspaceFilePath_KnownRuntimes — the runtime → base-path
|
||||
// map is the source of truth for where saved files land on the workspace
|
||||
// EC2. Changing a base path without a migration shim silently orphans
|
||||
// previously-saved files; this test pins the current contract.
|
||||
func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
|
||||
// TestResolveWorkspaceFilePath_RuntimeIndirection pins the
|
||||
// `?root="/configs"` (or empty / unrecognized) → runtime managed-config
|
||||
// dir behavior. Hermes uses /home/ubuntu/.hermes; claude-code uses
|
||||
// /configs; unknowns fall back to /configs. This indirection is the
|
||||
// reason hermes Config-tab edits land in the right place even though
|
||||
// the canvas only ever sends `?root=/configs`. Changing it without a
|
||||
// migration shim silently orphans previously-saved files.
|
||||
func TestResolveWorkspaceFilePath_RuntimeIndirection(t *testing.T) {
|
||||
cases := []struct {
|
||||
runtime string
|
||||
root string
|
||||
relPath string
|
||||
want string
|
||||
}{
|
||||
{"hermes", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
|
||||
{"HERMES", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
|
||||
{"hermes", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
|
||||
{"hermes", "/configs", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
|
||||
{"HERMES", "/configs", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
|
||||
{"hermes", "/configs", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
|
||||
{"hermes", "", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // empty root → runtime indirection
|
||||
{"hermes", "/etc", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // out-of-allowlist → runtime indirection
|
||||
// claude-code (and any future containerized runtime) lands at /configs —
|
||||
// the path user-data creates and bind-mounts into the container. Pre-fix
|
||||
// this fell through to /opt/configs which doesn't exist on workspace EC2s
|
||||
// and would 500 with EACCES on save (the bug that motivated this gate).
|
||||
{"claude-code", "config.yaml", "/configs/config.yaml"},
|
||||
{"CLAUDE-CODE", "config.yaml", "/configs/config.yaml"}, // case-insensitive
|
||||
{"langgraph", "config.yaml", "/opt/configs/config.yaml"},
|
||||
{"external", "skills.json", "/opt/configs/skills.json"},
|
||||
{"", "config.yaml", "/configs/config.yaml"}, // empty → default
|
||||
{"unknown", "config.yaml", "/configs/config.yaml"}, // unknown → default
|
||||
{"claude-code", "/configs", "config.yaml", "/configs/config.yaml"},
|
||||
{"CLAUDE-CODE", "/configs", "config.yaml", "/configs/config.yaml"}, // case-insensitive
|
||||
{"langgraph", "/configs", "config.yaml", "/opt/configs/config.yaml"},
|
||||
{"external", "/configs", "skills.json", "/opt/configs/skills.json"},
|
||||
{"", "/configs", "config.yaml", "/configs/config.yaml"}, // empty runtime → default
|
||||
{"unknown", "/configs", "config.yaml", "/configs/config.yaml"}, // unknown → default
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.runtime+"/"+tc.relPath, func(t *testing.T) {
|
||||
got, err := resolveWorkspaceFilePath(tc.runtime, tc.relPath)
|
||||
t.Run(tc.runtime+"+"+tc.root+"/"+tc.relPath, func(t *testing.T) {
|
||||
got, err := resolveWorkspaceFilePath(tc.runtime, tc.root, tc.relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveWorkspaceFilePath(%q,%q) = %q, want %q",
|
||||
tc.runtime, tc.relPath, got, tc.want)
|
||||
t.Errorf("resolveWorkspaceFilePath(%q,%q,%q) = %q, want %q",
|
||||
tc.runtime, tc.root, tc.relPath, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveWorkspaceFilePath_LiteralRoots pins that the universal
|
||||
// allow-listed roots (`/home`, `/workspace`, `/plugins`) pass through
|
||||
// LITERALLY rather than getting rewritten to the runtime prefix. This
|
||||
// is the half of the resolver that the FilesTab "/home" selector
|
||||
// depends on — without it, picking /home on a hermes workspace would
|
||||
// route to /home/ubuntu/.hermes (the runtime indirection) and the
|
||||
// canvas's tree row would never line up with what the user sees on
|
||||
// the EC2 host.
|
||||
func TestResolveWorkspaceFilePath_LiteralRoots(t *testing.T) {
|
||||
cases := []struct {
|
||||
runtime string
|
||||
root string
|
||||
relPath string
|
||||
want string
|
||||
}{
|
||||
// /home is always literal regardless of runtime — it's a
|
||||
// universal Linux path, not a managed-config indirection.
|
||||
{"hermes", "/home", "ubuntu/.bashrc", "/home/ubuntu/.bashrc"},
|
||||
{"claude-code", "/home", "ubuntu/notes.md", "/home/ubuntu/notes.md"},
|
||||
{"langgraph", "/home", "ubuntu/x", "/home/ubuntu/x"},
|
||||
// /workspace and /plugins are also literal — runtime is ignored.
|
||||
{"hermes", "/workspace", "src/main.go", "/workspace/src/main.go"},
|
||||
{"claude-code", "/plugins", "p/manifest.yaml", "/plugins/p/manifest.yaml"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.runtime+"+"+tc.root+"/"+tc.relPath, func(t *testing.T) {
|
||||
got, err := resolveWorkspaceFilePath(tc.runtime, tc.root, tc.relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveWorkspaceFilePath(%q,%q,%q) = %q, want %q",
|
||||
tc.runtime, tc.root, tc.relPath, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveWorkspaceRootPath pins the directory-only translation
|
||||
// used by listFilesViaEIC. Same indirection rules as
|
||||
// resolveWorkspaceFilePath but without joining a relative path.
|
||||
func TestResolveWorkspaceRootPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
runtime string
|
||||
root string
|
||||
want string
|
||||
}{
|
||||
{"hermes", "/configs", "/home/ubuntu/.hermes"},
|
||||
{"claude-code", "/configs", "/configs"},
|
||||
{"hermes", "", "/home/ubuntu/.hermes"},
|
||||
{"hermes", "/home", "/home"},
|
||||
{"claude-code", "/workspace", "/workspace"},
|
||||
{"hermes", "/plugins", "/plugins"},
|
||||
{"unknown", "/configs", "/configs"},
|
||||
{"hermes", "/etc", "/home/ubuntu/.hermes"}, // not allowlisted → runtime indirection
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.runtime+"+"+tc.root, func(t *testing.T) {
|
||||
got := resolveWorkspaceRootPath(tc.runtime, tc.root)
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveWorkspaceRootPath(%q,%q) = %q, want %q",
|
||||
tc.runtime, tc.root, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,48 +126,80 @@ func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
|
||||
// We only assert the cases that Clean() can't rescue.
|
||||
func TestResolveWorkspaceFilePath_RejectsTraversal(t *testing.T) {
|
||||
bad := []string{
|
||||
"../etc/shadow", // escapes base via ..
|
||||
"/etc/shadow", // absolute path
|
||||
"./../../etc", // multiple ..
|
||||
"a/../../etc", // escapes via deeper ..
|
||||
"../etc/shadow", // escapes base via ..
|
||||
"/etc/shadow", // absolute path
|
||||
"./../../etc", // multiple ..
|
||||
"a/../../etc", // escapes via deeper ..
|
||||
}
|
||||
for _, rel := range bad {
|
||||
t.Run(rel, func(t *testing.T) {
|
||||
_, err := resolveWorkspaceFilePath("hermes", rel)
|
||||
_, err := resolveWorkspaceFilePath("hermes", "/configs", rel)
|
||||
if err == nil {
|
||||
t.Errorf("resolveWorkspaceFilePath(hermes, %q) should have errored, got nil", rel)
|
||||
t.Errorf("resolveWorkspaceFilePath(hermes,/configs,%q) should have errored, got nil", rel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHArgs_LogLevelErrorBothSites pins that BOTH ssh invocations
|
||||
// (writeFileViaEIC + readFileViaEIC) include `-o LogLevel=ERROR`.
|
||||
// TestSSHArgs_HardenedFlags pins the ssh option set returned by
|
||||
// eicSSHSession.sshArgs(). Centralising the args was deliberate so a
|
||||
// fix like PR #2822's `LogLevel=ERROR` (silences the benign
|
||||
// known-hosts warning that fooled the read/list "empty stderr → not
|
||||
// found" classifier) only needs to land in one place.
|
||||
//
|
||||
// Without that flag, ssh emits a "Warning: Permanently added
|
||||
// '[127.0.0.1]:NNNNN' (ED25519) to the list of known hosts." line on
|
||||
// every fresh tunnel connection (even with UserKnownHostsFile=/dev/null
|
||||
// — that prevents persistence, not the warning). The warning lands on
|
||||
// stderr, which fools readFileViaEIC's "empty stdout + empty stderr →
|
||||
// file not found" classifier into thinking the warning is a real
|
||||
// ssh-layer error and returning 500 instead of 404.
|
||||
//
|
||||
// Caught 2026-05-05 02:38 on hongming.moleculesai.app: opening Hermes
|
||||
// workspace's Config tab returned 500 with body
|
||||
// Caught 2026-05-05 02:38 on hongming.moleculesai.app: opening
|
||||
// Hermes workspace's Config tab returned 500 with body
|
||||
// `ssh cat: exit status 1 (Warning: Permanently added '[127.0.0.1]:37951'…)`.
|
||||
//
|
||||
// LogLevel=ERROR silences info+warning while keeping real auth/tunnel
|
||||
// errors visible. This test reads the source and asserts the flag
|
||||
// appears at least twice (one per ssh block) — fires if a future edit
|
||||
// removes it from either site.
|
||||
func TestSSHArgs_LogLevelErrorBothSites(t *testing.T) {
|
||||
// Asserts each load-bearing flag appears in the args slice — fires if
|
||||
// a future edit removes any of them.
|
||||
func TestSSHArgs_HardenedFlags(t *testing.T) {
|
||||
s := eicSSHSession{keyPath: "/tmp/k", localPort: 12345, osUser: "ubuntu", instanceID: "i-x"}
|
||||
got := s.sshArgs("echo hi")
|
||||
wantFragments := [][]string{
|
||||
{"-i", "/tmp/k"},
|
||||
{"-o", "StrictHostKeyChecking=no"},
|
||||
{"-o", "UserKnownHostsFile=/dev/null"},
|
||||
{"-o", "LogLevel=ERROR"},
|
||||
{"-o", "ServerAliveInterval=15"},
|
||||
{"-p", "12345"},
|
||||
}
|
||||
joined := strings.Join(got, " ")
|
||||
for _, frag := range wantFragments {
|
||||
if !strings.Contains(joined, strings.Join(frag, " ")) {
|
||||
t.Errorf("sshArgs() missing fragment %v; got: %v", frag, got)
|
||||
}
|
||||
}
|
||||
// Last two args must be `<user>@127.0.0.1` then the remote command.
|
||||
if got[len(got)-2] != "ubuntu@127.0.0.1" {
|
||||
t.Errorf("sshArgs() second-last must be user@127.0.0.1; got: %q", got[len(got)-2])
|
||||
}
|
||||
if got[len(got)-1] != "echo hi" {
|
||||
t.Errorf("sshArgs() last must be the remote command; got: %q", got[len(got)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEicSSHSessionSingleSourceForSSHFlags is a structural guard: the
|
||||
// production EIC source must invoke s.sshArgs() exclusively for ssh
|
||||
// invocations — direct ssh args inlined in any helper would re-open
|
||||
// the regression that PR #2822 closed (LogLevel=ERROR drift between
|
||||
// helpers). Counts `s.sshArgs(` occurrences (one per file op) and
|
||||
// fails if anyone copy-pastes a raw ssh args slice.
|
||||
func TestEicSSHSessionSingleSourceForSSHFlags(t *testing.T) {
|
||||
src, err := os.ReadFile("template_files_eic.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read source: %v", err)
|
||||
}
|
||||
matches := regexp.MustCompile(`"-o", "LogLevel=ERROR"`).FindAllIndex(src, -1)
|
||||
if len(matches) < 2 {
|
||||
t.Errorf("expected LogLevel=ERROR in BOTH ssh blocks (write + read); found %d occurrences", len(matches))
|
||||
// Each of write/read/list/delete should call s.sshArgs once.
|
||||
matches := regexp.MustCompile(`s\.sshArgs\(`).FindAllIndex(src, -1)
|
||||
if len(matches) < 4 {
|
||||
t.Errorf("expected ≥4 s.sshArgs() callers (write/read/list/delete); found %d", len(matches))
|
||||
}
|
||||
// Belt and braces: no helper should be assembling its own
|
||||
// `LogLevel=ERROR` literal outside of sshArgs.
|
||||
literal := regexp.MustCompile(`"-o", "LogLevel=ERROR"`).FindAllIndex(src, -1)
|
||||
if len(literal) != 1 {
|
||||
t.Errorf("LogLevel=ERROR must appear exactly once (in sshArgs); found %d occurrences — drift risk", len(literal))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,12 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
|
||||
// as a follow-up.
|
||||
if instanceID != "" {
|
||||
for relPath, content := range body.Files {
|
||||
if err := writeFileViaEIC(ctx, instanceID, runtime, relPath, []byte(content)); err != nil {
|
||||
// ReplaceFiles is a bulk template-import endpoint — files
|
||||
// always land in the runtime's managed-config dir. Pass
|
||||
// "/configs" so resolveWorkspaceFilePath routes through the
|
||||
// runtime prefix map (matches the local-Docker arm below
|
||||
// which always copies to /configs).
|
||||
if err := writeFileViaEIC(ctx, instanceID, runtime, "/configs", relPath, []byte(content)); err != nil {
|
||||
log.Printf("ReplaceFiles EIC for %s path=%s: %v", workspaceID, relPath, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s: %v", relPath, err)})
|
||||
return
|
||||
|
||||
@@ -243,8 +243,11 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
listPath = rootPath + "/" + subPath
|
||||
}
|
||||
|
||||
var wsName string
|
||||
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
|
||||
var wsName, instanceID, runtime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsName, &instanceID, &runtime); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
@@ -255,6 +258,32 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
Dir bool `json:"dir"`
|
||||
}
|
||||
|
||||
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. List
|
||||
// via SSH through the EIC endpoint, mirroring ReadFile/WriteFile's
|
||||
// dispatch. Pre-fix this branch was missing and SaaS workspaces
|
||||
// always fell through to local-Docker check (finds nothing on a SaaS
|
||||
// tenant) + template-dir fallback (returns the seed template, not
|
||||
// the persisted state, and almost never matches on user-named
|
||||
// workspaces). Net effect: the canvas Files tab always rendered "0
|
||||
// files / No config files yet" for SaaS workspaces, regardless of
|
||||
// what was actually on disk. See issue #2999.
|
||||
if instanceID != "" {
|
||||
entries, err := listFilesViaEIC(ctx, instanceID, runtime, rootPath, subPath, depth)
|
||||
if err != nil {
|
||||
log.Printf("ListFiles EIC for %s root=%s sub=%s: %v", workspaceID, rootPath, subPath, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list files: %v", err)})
|
||||
return
|
||||
}
|
||||
// Translate to the handler's wire shape (the field names match
|
||||
// 1:1, but Go can't implicit-convert named struct types).
|
||||
out := make([]fileEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, fileEntry{Path: e.Path, Size: e.Size, Dir: e.Dir})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
// Try container filesystem first
|
||||
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
|
||||
// Portable file listing: works on both GNU and BusyBox/Alpine.
|
||||
@@ -378,12 +407,13 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
// canvas Config tab always 404'd for SaaS workspaces — visible to
|
||||
// users after #2781 added the "no config.yaml" error UX.
|
||||
//
|
||||
// The ?root= query param is intentionally ignored on the SaaS path —
|
||||
// it's a local-Docker concept (arbitrary container roots). The
|
||||
// runtime → base-path map (workspaceFilePathPrefix in
|
||||
// template_files_eic.go) is the SaaS source of truth.
|
||||
// `?root=` flows through resolveWorkspaceFilePath: "/configs" stays
|
||||
// the per-runtime managed-config indirection (claude-code → /configs,
|
||||
// hermes → /home/ubuntu/.hermes); other allow-listed roots
|
||||
// (`/home`, `/workspace`, `/plugins`) pass through literally so
|
||||
// list/read/write/delete agree on what file a tree row points to.
|
||||
if instanceID != "" {
|
||||
content, err := readFileViaEIC(ctx, instanceID, runtime, filePath)
|
||||
content, err := readFileViaEIC(ctx, instanceID, runtime, rootPath, filePath)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"path": filePath,
|
||||
@@ -468,6 +498,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
|
||||
@@ -479,8 +514,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
|
||||
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Write
|
||||
// via SSH through the EIC endpoint to the runtime-specific path.
|
||||
// `?root=` flows through the same per-runtime / literal indirection
|
||||
// as ReadFile so list/read/write/delete agree on what file a tree
|
||||
// row points to.
|
||||
if instanceID != "" {
|
||||
if err := writeFileViaEIC(ctx, instanceID, runtime, filePath, []byte(body.Content)); err != nil {
|
||||
if err := writeFileViaEIC(ctx, instanceID, runtime, rootPath, filePath, []byte(body.Content)); err != nil {
|
||||
log.Printf("WriteFile EIC for %s path=%s: %v", workspaceID, filePath, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
|
||||
return
|
||||
@@ -528,12 +566,35 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var wsName string
|
||||
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsName, &instanceID, &runtime); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Delete
|
||||
// via SSH through the EIC endpoint, mirroring ReadFile/WriteFile's
|
||||
// dispatch. Pre-fix this branch was missing — DeleteFile fell through
|
||||
// to local-Docker (no container) + ephemeral-volume (no Docker) and
|
||||
// silently 500'd. See issue #2999.
|
||||
if instanceID != "" {
|
||||
if err := deleteFileViaEIC(ctx, instanceID, runtime, rootPath, filePath); err != nil {
|
||||
log.Printf("DeleteFile EIC for %s root=%s path=%s: %v", workspaceID, rootPath, filePath, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete file: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete via docker exec when container is running
|
||||
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
|
||||
// CWE-78: use filepath.Join instead of string concat to prevent path
|
||||
|
||||
@@ -750,7 +750,11 @@ func TestListFiles_WorkspaceNotFound(t *testing.T) {
|
||||
|
||||
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
|
||||
// SQL shape: SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1
|
||||
// (matches the L/R/W/D unified shape so dispatchers can branch on
|
||||
// instance_id; sqlmock matches via QueryMatcherRegexp so the parens
|
||||
// need escaping.)
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-nonexist").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
@@ -777,9 +781,9 @@ func TestListFiles_FallbackToHost_NoTemplate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
handler := NewTemplatesHandler(tmpDir, nil, nil) // nil docker = no container
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-fallback").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Unknown Agent"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Unknown Agent", "", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -817,9 +821,9 @@ func TestListFiles_FallbackToHost_WithTemplate(t *testing.T) {
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil, nil)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-tmpl").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Test Agent"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Test Agent", "", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1103,7 +1107,7 @@ func TestDeleteFile_WorkspaceNotFound(t *testing.T) {
|
||||
|
||||
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-del-nf").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
|
||||
@@ -112,7 +112,6 @@ func (h *WorkspaceHandler) SetCPProvisioner(cp provisioner.CPProvisionerAPI) {
|
||||
h.cpProv = cp
|
||||
}
|
||||
|
||||
|
||||
// SetEnvMutators wires a provisionhook.Registry into the handler. Plugins
|
||||
// living in separate repos register on the same Registry instance during
|
||||
// boot (see cmd/server/main.go) and main.go calls this setter once before
|
||||
@@ -361,7 +360,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// populate the Runtime pill on the side panel immediately — without it
|
||||
// the node lives as "runtime: unknown" until something refetches the
|
||||
// workspace row (which nothing does during provisioning).
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), id, map[string]interface{}{
|
||||
"name": payload.Name,
|
||||
"tier": payload.Tier,
|
||||
"runtime": payload.Runtime,
|
||||
@@ -388,7 +387,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
|
||||
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), id, map[string]interface{}{
|
||||
"name": payload.Name, "external": true,
|
||||
})
|
||||
} else {
|
||||
@@ -407,7 +406,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
} else {
|
||||
connectionToken = tok
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_AWAITING_AGENT", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceAwaitingAgent), id, map[string]interface{}{
|
||||
"name": payload.Name, "external": true,
|
||||
})
|
||||
}
|
||||
@@ -539,24 +538,24 @@ func scanWorkspaceRow(rows interface {
|
||||
}
|
||||
|
||||
ws := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"tier": tier,
|
||||
"status": status,
|
||||
"url": url,
|
||||
"parent_id": parentID,
|
||||
"active_tasks": activeTasks,
|
||||
"max_concurrent_tasks": maxConcurrentTasks,
|
||||
"last_error_rate": errorRate,
|
||||
"last_sample_error": sampleError,
|
||||
"uptime_seconds": uptimeSeconds,
|
||||
"current_task": currentTask,
|
||||
"runtime": runtime,
|
||||
"workspace_dir": nilIfEmpty(workspaceDir),
|
||||
"monthly_spend": monthlySpend,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"collapsed": collapsed,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"tier": tier,
|
||||
"status": status,
|
||||
"url": url,
|
||||
"parent_id": parentID,
|
||||
"active_tasks": activeTasks,
|
||||
"max_concurrent_tasks": maxConcurrentTasks,
|
||||
"last_error_rate": errorRate,
|
||||
"last_sample_error": sampleError,
|
||||
"uptime_seconds": uptimeSeconds,
|
||||
"current_task": currentTask,
|
||||
"runtime": runtime,
|
||||
"workspace_dir": nilIfEmpty(workspaceDir),
|
||||
"monthly_spend": monthlySpend,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"collapsed": collapsed,
|
||||
}
|
||||
|
||||
// budget_limit: nil when no limit set, int64 otherwise
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -85,7 +86,7 @@ func (h *WorkspaceHandler) BootstrapFailed(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(c.Request.Context(), "WORKSPACE_PROVISION_FAILED", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(c.Request.Context(), string(events.EventWorkspaceProvisionFailed), id, map[string]interface{}{
|
||||
"error": errMsg,
|
||||
"log_tail": tail,
|
||||
"source": "bootstrap_watcher",
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// State handles GET /workspaces/:id/state — minimal status payload for
|
||||
// remote-agent polling (Phase 30.4). Returns `{status, paused, deleted,
|
||||
// workspace_id}` so a remote agent can detect pause/resume/delete
|
||||
@@ -380,7 +382,7 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||
pq.Array(allIDs)); err != nil {
|
||||
log.Printf("Delete token revocation error for %s: %v", id, err)
|
||||
}
|
||||
// #1027: cascade-disable all schedules for the deleted workspaces so
|
||||
// #1027: cascade-disable all schedules for the deleted workspaces so
|
||||
// the scheduler never fires a cron into a removed container.
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspace_schedules SET enabled = false, updated_at = now()
|
||||
@@ -466,14 +468,14 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
||||
// leaving other WS clients ignorant of the cascade. The DB
|
||||
// row is already 'removed' so it's recoverable, but the
|
||||
// inconsistency is avoidable.
|
||||
h.broadcaster.RecordAndBroadcast(cleanupCtx, "WORKSPACE_REMOVED", descID, map[string]interface{}{})
|
||||
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), descID, map[string]interface{}{})
|
||||
}
|
||||
|
||||
stopAndRemove(id)
|
||||
db.ClearWorkspaceKeys(cleanupCtx, id)
|
||||
restartStates.Delete(id) // #2269: same as descendants above
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(cleanupCtx, "WORKSPACE_REMOVED", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), id, map[string]interface{}{
|
||||
"cascade_deleted": len(descendantIDs),
|
||||
})
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
@@ -212,7 +213,7 @@ func (h *WorkspaceHandler) markProvisionFailed(ctx context.Context, workspaceID,
|
||||
} else if _, hasErr := extra["error"]; !hasErr {
|
||||
extra["error"] = msg
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, extra)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), workspaceID, extra)
|
||||
if _, dbErr := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $3, last_sample_error = $2, updated_at = now() WHERE id = $1`,
|
||||
workspaceID, msg, models.StatusFailed); dbErr != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provlog"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -147,7 +148,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
// Reset to provisioning
|
||||
db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, url = '', updated_at = now() WHERE id = $2`, models.StatusProvisioning, id)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), id, map[string]interface{}{
|
||||
"name": wsName,
|
||||
"tier": tier,
|
||||
"runtime": containerRuntime,
|
||||
@@ -341,7 +342,7 @@ func (h *WorkspaceHandler) HibernateWorkspace(ctx context.Context, workspaceID s
|
||||
}
|
||||
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_HIBERNATED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceHibernated), workspaceID, map[string]interface{}{
|
||||
"name": wsName,
|
||||
"tier": tier,
|
||||
})
|
||||
@@ -552,7 +553,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
|
||||
db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, url = '', updated_at = now() WHERE id = $2`, models.StatusProvisioning, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), workspaceID, map[string]interface{}{
|
||||
"name": wsName, "tier": tier, "runtime": dbRuntime,
|
||||
})
|
||||
|
||||
@@ -640,7 +641,7 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
|
||||
db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, url = '', updated_at = now() WHERE id = $2`, models.StatusPaused, ws.id)
|
||||
db.ClearWorkspaceKeys(ctx, ws.id)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PAUSED", ws.id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspacePaused), ws.id, map[string]interface{}{
|
||||
"name": ws.name,
|
||||
})
|
||||
}
|
||||
@@ -709,7 +710,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
|
||||
for _, ws := range toResume {
|
||||
db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusProvisioning, ws.id)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", ws.id, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
|
||||
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
|
||||
})
|
||||
payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -340,7 +341,7 @@ func decodeError(resp *http.Response) error {
|
||||
// have rather than dropping it.
|
||||
return &contract.Error{
|
||||
Code: httpStatusToCode(resp.StatusCode),
|
||||
Message: fmt.Sprintf("status %d: %s", resp.StatusCode, truncate(string(body), 256)),
|
||||
Message: fmt.Sprintf("status %d: %s", resp.StatusCode, textutil.TruncateBytes(string(body), 256)),
|
||||
}
|
||||
}
|
||||
return &e
|
||||
@@ -359,12 +360,7 @@ func httpStatusToCode(status int) contract.ErrorCode {
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
|
||||
|
||||
// --- Circuit breaker ---
|
||||
|
||||
|
||||
@@ -499,14 +499,10 @@ func TestHttpStatusToCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("short", 10); got != "short" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := truncate(strings.Repeat("a", 300), 10); !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("expected ellipsis: %q", got)
|
||||
}
|
||||
}
|
||||
// Truncate moved to internal/textutil — coverage lives in
|
||||
// internal/textutil/truncate_test.go (TestTruncateBytes_RuneBoundary).
|
||||
// memory/client just calls it as a wire-shape helper for error
|
||||
// messages; no client-specific behavior to pin here.
|
||||
|
||||
// --- Circuit breaker ---
|
||||
|
||||
|
||||
@@ -213,10 +213,15 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
|
||||
|
||||
// expectChainQuery sets up the recursive-CTE expectation matching
|
||||
// the resolver for a root workspace. Reusable across tests.
|
||||
//
|
||||
// The resolver SELECTs `name` so it can populate Namespace.DisplayName
|
||||
// (#2988); we pass an empty string here because the e2e tests don't
|
||||
// assert on label rendering — the namespace string ("workspace:root-1"
|
||||
// etc) is what the plugin sees.
|
||||
func expectChainQueryRoot(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectQuery("WITH RECURSIVE chain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("root-1", nil, 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("root-1", "", nil, 0))
|
||||
}
|
||||
|
||||
// --- The actual E2E ---
|
||||
|
||||
@@ -33,6 +33,25 @@ type Namespace struct {
|
||||
Kind contract.NamespaceKind `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
Writable bool `json:"writable"`
|
||||
// DisplayName is the human-readable label for this namespace,
|
||||
// derived from the workspace tree:
|
||||
// - workspace: this workspace's own name (`workspaces.name`)
|
||||
// - team: parent's name if child, this workspace's name if root
|
||||
// (degenerate case — team semantically means "memories
|
||||
// shared with peers in this team", so for a root workspace
|
||||
// with no peers, "your team" is conceptually correct.)
|
||||
// - org: the root workspace's name (org-wide memories — every
|
||||
// workspace under this root sees them)
|
||||
//
|
||||
// Empty string when the lookup failed (workspace row missing). The
|
||||
// canvas uses DisplayName for the dropdown; falls back to a short
|
||||
// UUID prefix when it's empty.
|
||||
//
|
||||
// Issue #2988: pre-fix, the canvas labelled all three namespaces
|
||||
// with the SAME shortID-truncated UUID prefix on a root workspace
|
||||
// because workspace==team==org IDs collide. The display name
|
||||
// disambiguates them by surfacing real workspace names.
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// ErrWorkspaceNotFound is returned when the input workspace ID does
|
||||
@@ -54,6 +73,7 @@ func New(db *sql.DB) *Resolver {
|
||||
// chainNode is one row from the recursive CTE.
|
||||
type chainNode struct {
|
||||
id string
|
||||
name string // workspaces.name (display label for the namespace)
|
||||
parentID *string
|
||||
depth int
|
||||
}
|
||||
@@ -64,16 +84,16 @@ type chainNode struct {
|
||||
func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNode, error) {
|
||||
const query = `
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT id, parent_id, 0 AS depth
|
||||
SELECT id, name, parent_id, 0 AS depth
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id, c.depth + 1
|
||||
SELECT w.id, w.name, w.parent_id, c.depth + 1
|
||||
FROM workspaces w
|
||||
JOIN chain c ON w.id = c.parent_id
|
||||
WHERE c.depth < $2
|
||||
)
|
||||
SELECT id::text, parent_id::text, depth FROM chain ORDER BY depth ASC
|
||||
SELECT id::text, COALESCE(name, ''), parent_id::text, depth FROM chain ORDER BY depth ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query, workspaceID, maxChainDepth)
|
||||
if err != nil {
|
||||
@@ -85,7 +105,7 @@ func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNo
|
||||
for rows.Next() {
|
||||
var n chainNode
|
||||
var parentStr sql.NullString
|
||||
if err := rows.Scan(&n.id, &parentStr, &n.depth); err != nil {
|
||||
if err := rows.Scan(&n.id, &n.name, &parentStr, &n.depth); err != nil {
|
||||
return nil, fmt.Errorf("scan chain: %w", err)
|
||||
}
|
||||
if parentStr.Valid && parentStr.String != "" {
|
||||
@@ -122,6 +142,33 @@ func derive(chain []chainNode) (workspace, team, org string) {
|
||||
return
|
||||
}
|
||||
|
||||
// deriveNames computes the display name for each of the three
|
||||
// canonical namespaces. Mirrors derive() — same lookup logic, but
|
||||
// returns workspace/parent/root NAMES instead of IDs.
|
||||
//
|
||||
// For a root workspace (no parent), team and org both alias to self;
|
||||
// callers should still render them as semantically distinct (the
|
||||
// `kind` field on the Namespace carries that distinction). The name
|
||||
// itself collides on a depth-1 tree — that's expected; the kind
|
||||
// prefix in the canvas label disambiguates.
|
||||
//
|
||||
// Returns the empty string for any name that's missing on the chain
|
||||
// row (defensive — workspaces.name is NOT NULL today, but a future
|
||||
// migration could change that). Callers fall back to UUID prefix
|
||||
// when DisplayName is empty.
|
||||
func deriveNames(chain []chainNode) (workspace, team, org string) {
|
||||
self := chain[0]
|
||||
workspace = self.name
|
||||
if self.parentID != nil && len(chain) > 1 {
|
||||
// Parent is the next node in the chain (depth 1).
|
||||
team = chain[1].name
|
||||
} else {
|
||||
team = self.name
|
||||
}
|
||||
org = chain[len(chain)-1].name
|
||||
return
|
||||
}
|
||||
|
||||
// ReadableNamespaces returns the namespaces the workspace can read
|
||||
// from. Order is deterministic (workspace, team, org) so callers can
|
||||
// reason about precedence.
|
||||
@@ -131,6 +178,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
|
||||
return nil, err
|
||||
}
|
||||
wsID, teamID, orgID := derive(chain)
|
||||
wsName, teamName, orgName := deriveNames(chain)
|
||||
isRoot := chain[0].parentID == nil
|
||||
|
||||
out := []Namespace{
|
||||
@@ -139,12 +187,14 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
|
||||
Kind: contract.NamespaceKindWorkspace,
|
||||
Description: "This workspace's private memories",
|
||||
Writable: true,
|
||||
DisplayName: wsName,
|
||||
},
|
||||
{
|
||||
Name: "team:" + teamID,
|
||||
Kind: contract.NamespaceKindTeam,
|
||||
Description: "Memories shared across team members (parent + siblings)",
|
||||
Writable: true,
|
||||
DisplayName: teamName,
|
||||
},
|
||||
}
|
||||
// Org namespace is readable by every workspace in the tree, but
|
||||
@@ -155,6 +205,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
|
||||
Kind: contract.NamespaceKindOrg,
|
||||
Description: "Org-wide memories visible to every workspace under this root",
|
||||
Writable: isRoot,
|
||||
DisplayName: orgName,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ func TestWalkChain_RootOnly(t *testing.T) {
|
||||
// Root workspace: parent_id is NULL, depth 0, single row.
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ws-root", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("ws-root", nil, 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("ws-root", "", nil, 0))
|
||||
|
||||
chain, err := r.walkChain(context.Background(), "ws-root")
|
||||
if err != nil {
|
||||
@@ -68,9 +68,9 @@ func TestWalkChain_ChildToParent(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ws-child", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("ws-child", "ws-root", 0).
|
||||
AddRow("ws-root", nil, 1))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("ws-child", "", "ws-root", 0).
|
||||
AddRow("ws-root", "", nil, 1))
|
||||
|
||||
chain, err := r.walkChain(context.Background(), "ws-child")
|
||||
if err != nil {
|
||||
@@ -93,7 +93,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) {
|
||||
r := New(db)
|
||||
|
||||
// Simulate a 51-deep chain: should be capped at maxChainDepth.
|
||||
rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"})
|
||||
rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})
|
||||
for i := 0; i <= maxChainDepth; i++ {
|
||||
var parent interface{}
|
||||
if i < maxChainDepth {
|
||||
@@ -101,7 +101,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) {
|
||||
} else {
|
||||
parent = nil // would be the cap point
|
||||
}
|
||||
rows.AddRow("ws-"+itoa(i), parent, i)
|
||||
rows.AddRow("ws-"+itoa(i), "", parent, i)
|
||||
}
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ws-0", maxChainDepth).
|
||||
@@ -127,7 +127,7 @@ func TestWalkChain_WorkspaceNotFound(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ws-missing", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
|
||||
|
||||
_, err := r.walkChain(context.Background(), "ws-missing")
|
||||
if !errors.Is(err, ErrWorkspaceNotFound) {
|
||||
@@ -172,8 +172,8 @@ func TestWalkChain_RowsErr(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ws-x", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("ws-x", nil, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("ws-x", "", nil, 0).
|
||||
RowError(0, errors.New("mid-iteration")))
|
||||
|
||||
_, err := r.walkChain(context.Background(), "ws-x")
|
||||
@@ -238,8 +238,8 @@ func TestReadableNamespaces_Root(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("root-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("root-1", nil, 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("root-1", "", nil, 0))
|
||||
|
||||
got, err := r.ReadableNamespaces(context.Background(), "root-1")
|
||||
if err != nil {
|
||||
@@ -274,9 +274,9 @@ func TestReadableNamespaces_Child(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("child-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("child-1", "root-1", 0).
|
||||
AddRow("root-1", nil, 1))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("child-1", "", "root-1", 0).
|
||||
AddRow("root-1", "", nil, 1))
|
||||
|
||||
got, err := r.ReadableNamespaces(context.Background(), "child-1")
|
||||
if err != nil {
|
||||
@@ -297,13 +297,93 @@ func TestReadableNamespaces_Child(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadableNamespaces_DisplayName_Root(t *testing.T) {
|
||||
// Root workspace with a real name. All three derived namespaces
|
||||
// (workspace/team/org) should carry the workspace's display name —
|
||||
// for a root workspace they collapse on UUID but the name is the
|
||||
// disambiguator surfaced in the canvas dropdown (issue #2988).
|
||||
db, mock := setupMockDB(t)
|
||||
r := New(db)
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("root-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("root-1", "mac laptop", nil, 0))
|
||||
|
||||
got, err := r.ReadableNamespaces(context.Background(), "root-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadableNamespaces: %v", err)
|
||||
}
|
||||
for i, ns := range got {
|
||||
if ns.DisplayName != "mac laptop" {
|
||||
t.Errorf("[%d] %q DisplayName = %q, want %q", i, ns.Name, ns.DisplayName, "mac laptop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadableNamespaces_DisplayName_Child(t *testing.T) {
|
||||
// Child has its own workspace name; team should pick up the
|
||||
// PARENT's name (not the child's), and org follows the chain root.
|
||||
db, mock := setupMockDB(t)
|
||||
r := New(db)
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("child-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("child-1", "Hongming Personal Brand", "root-1", 0).
|
||||
AddRow("root-1", "mac laptop", nil, 1))
|
||||
|
||||
got, err := r.ReadableNamespaces(context.Background(), "child-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadableNamespaces: %v", err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"workspace:child-1": "Hongming Personal Brand", // self
|
||||
"team:root-1": "mac laptop", // parent
|
||||
"org:root-1": "mac laptop", // root
|
||||
}
|
||||
for _, ns := range got {
|
||||
w, ok := want[ns.Name]
|
||||
if !ok {
|
||||
t.Errorf("unexpected namespace %q", ns.Name)
|
||||
continue
|
||||
}
|
||||
if ns.DisplayName != w {
|
||||
t.Errorf("%q DisplayName = %q, want %q", ns.Name, ns.DisplayName, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadableNamespaces_DisplayName_EmptyOnNULL(t *testing.T) {
|
||||
// COALESCE in the query produces "" when name is NULL. The
|
||||
// resolver must propagate that as DisplayName="" so the handler's
|
||||
// label shaper can fall back to the UUID-prefix shape.
|
||||
db, mock := setupMockDB(t)
|
||||
r := New(db)
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("root-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("root-1", "", nil, 0))
|
||||
|
||||
got, err := r.ReadableNamespaces(context.Background(), "root-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadableNamespaces: %v", err)
|
||||
}
|
||||
for _, ns := range got {
|
||||
if ns.DisplayName != "" {
|
||||
t.Errorf("%q DisplayName = %q, want empty (NULL fallback)", ns.Name, ns.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadableNamespaces_NotFound(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
r := New(db)
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ghost", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
|
||||
|
||||
_, err := r.ReadableNamespaces(context.Background(), "ghost")
|
||||
if !errors.Is(err, ErrWorkspaceNotFound) {
|
||||
@@ -319,8 +399,8 @@ func TestWritableNamespaces_RootSeesAll(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("root-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("root-1", nil, 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("root-1", "", nil, 0))
|
||||
|
||||
got, err := r.WritableNamespaces(context.Background(), "root-1")
|
||||
if err != nil {
|
||||
@@ -337,9 +417,9 @@ func TestWritableNamespaces_ChildExcludesOrg(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("child-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("child-1", "root-1", 0).
|
||||
AddRow("root-1", nil, 1))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("child-1", "", "root-1", 0).
|
||||
AddRow("root-1", "", nil, 1))
|
||||
|
||||
got, err := r.WritableNamespaces(context.Background(), "child-1")
|
||||
if err != nil {
|
||||
@@ -361,7 +441,7 @@ func TestWritableNamespaces_NotFound(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ghost", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
|
||||
|
||||
_, err := r.WritableNamespaces(context.Background(), "ghost")
|
||||
if !errors.Is(err, ErrWorkspaceNotFound) {
|
||||
@@ -390,9 +470,9 @@ func TestCanWrite(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
r := New(db)
|
||||
rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"})
|
||||
rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})
|
||||
if tc.isRoot {
|
||||
rows.AddRow("root-1", nil, 0)
|
||||
rows.AddRow("root-1", "", nil, 0)
|
||||
mock.ExpectQuery(chainQuerySnippet).WithArgs("root-1", maxChainDepth).WillReturnRows(rows)
|
||||
ok, err := r.CanWrite(context.Background(), "root-1", tc.namespace)
|
||||
if err != nil {
|
||||
@@ -402,7 +482,7 @@ func TestCanWrite(t *testing.T) {
|
||||
t.Errorf("CanWrite(%q) = %v, want %v", tc.namespace, ok, tc.want)
|
||||
}
|
||||
} else {
|
||||
rows.AddRow("child-1", "root-1", 0).AddRow("root-1", nil, 1)
|
||||
rows.AddRow("child-1", "", "root-1", 0).AddRow("root-1", "", nil, 1)
|
||||
mock.ExpectQuery(chainQuerySnippet).WithArgs("child-1", maxChainDepth).WillReturnRows(rows)
|
||||
ok, err := r.CanWrite(context.Background(), "child-1", tc.namespace)
|
||||
if err != nil {
|
||||
@@ -435,9 +515,9 @@ func TestIntersectReadable_DefaultAll(t *testing.T) {
|
||||
r := New(db)
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("child-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("child-1", "root-1", 0).
|
||||
AddRow("root-1", nil, 1))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("child-1", "", "root-1", 0).
|
||||
AddRow("root-1", "", nil, 1))
|
||||
|
||||
// Empty requested → return everything readable.
|
||||
got, err := r.IntersectReadable(context.Background(), "child-1", nil)
|
||||
@@ -455,9 +535,9 @@ func TestIntersectReadable_Filters(t *testing.T) {
|
||||
r := New(db)
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("child-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("child-1", "root-1", 0).
|
||||
AddRow("root-1", nil, 1))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("child-1", "", "root-1", 0).
|
||||
AddRow("root-1", "", nil, 1))
|
||||
|
||||
// Requested: one allowed, one disallowed (foreign workspace), one allowed
|
||||
requested := []string{"workspace:child-1", "workspace:foreign", "team:root-1"}
|
||||
@@ -476,8 +556,8 @@ func TestIntersectReadable_AllFiltered(t *testing.T) {
|
||||
r := New(db)
|
||||
mock.ExpectQuery(chainQuerySnippet).
|
||||
WithArgs("ws-1", maxChainDepth).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
|
||||
AddRow("ws-1", nil, 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
|
||||
AddRow("ws-1", "", nil, 0))
|
||||
|
||||
// Request only namespaces the caller cannot read.
|
||||
got, err := r.IntersectReadable(context.Background(), "ws-1", []string{"workspace:other", "team:other"})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package messagestore defines the read-side interface and canonical
|
||||
// data shapes for chat-history retrieval.
|
||||
//
|
||||
// Origin: RFC #2945 PR-D (issue #3026). PR-A extracted the WRITE path
|
||||
// (AgentMessageWriter), PR-B/B-1 typed the WS event taxonomy, PR-C
|
||||
// centralized read-side parsing in the server. PR-D abstracts the
|
||||
// underlying storage layer so OSS operators can plug in alternative
|
||||
// backends without forking the handler.
|
||||
//
|
||||
// # Why this package exists
|
||||
//
|
||||
// Today's only consumer is ChatHistoryHandler, but exposing storage as
|
||||
// an interface is what makes the platform's chat-history layer pluggable
|
||||
// for OSS operators. Operators wanting to:
|
||||
//
|
||||
// - Tier hot/warm/cold storage (recent in Postgres, archival in S3 parquet)
|
||||
// - Use a vector store with hybrid search (Pinecone, Weaviate)
|
||||
// - Run an in-memory store for ephemeral tests / sandbox tenants
|
||||
// - Federate history across regions
|
||||
//
|
||||
// …implement MessageStore against their backend. The platform-default
|
||||
// PostgresMessageStore wraps today's activity_logs query + parser
|
||||
// behavior unchanged.
|
||||
//
|
||||
// # Implementation contract
|
||||
//
|
||||
// Implementations MUST:
|
||||
//
|
||||
// - Return messages newest-first, up to opts.Limit. Caller (the
|
||||
// handler) is responsible for opts.Limit clamping.
|
||||
// - Honor opts.BeforeTS as a strict less-than cursor when
|
||||
// opts.HasBefore is true; ignore it when false. Use HasBefore (not
|
||||
// a zero-time check) so a legitimate "start of epoch" cursor is
|
||||
// distinguishable from "no cursor."
|
||||
// - Set reachedEnd=true when the underlying store has no more
|
||||
// messages older than the returned page. Caller uses this to
|
||||
// disable further older-batch fetches in the lazy-load UX.
|
||||
// - Parse agent-emitted JSON DEFENSIVELY. Any malformed message body
|
||||
// becomes an empty ChatMessage (or is dropped); never panic, never
|
||||
// return an error for parse failures alone — chat falls through to
|
||||
// text-only rather than 500.
|
||||
// - NEVER log full message bodies, attachment URIs, or anything that
|
||||
// would be a sensitive screenshot. Workspace ID + activity-log
|
||||
// row id at DEBUG is the ceiling.
|
||||
// - Honor ctx cancellation. A canceled ctx must abort the lookup
|
||||
// and return ctx.Err().
|
||||
//
|
||||
// Implementations MAY:
|
||||
//
|
||||
// - Cache aggressively (history is read-only).
|
||||
// - Filter out additional rows beyond what the interface requires
|
||||
// (e.g., role-based redaction in regulated environments) as long
|
||||
// as reachedEnd is set conservatively (false if uncertain).
|
||||
//
|
||||
// # Threading
|
||||
//
|
||||
// Implementations MUST be safe for concurrent calls. The handler
|
||||
// dispatches a goroutine per request; a non-thread-safe impl would
|
||||
// race on every chat reload.
|
||||
package messagestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChatMessage is the canonical shape returned to chat-history clients.
|
||||
// Mirrors canvas's ChatMessage TS type so the canvas can render
|
||||
// without per-row mapping.
|
||||
//
|
||||
// ID is server-minted per ChatMessage. Activity-log rows don't carry
|
||||
// message-shaped ids; canvas dedupes by (role, content, timestamp
|
||||
// window) not by id, so id stability across requests is not required.
|
||||
type ChatMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"` // "user" | "agent" | "system"
|
||||
Content string `json:"content"`
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
Timestamp string `json:"timestamp"` // RFC3339, pinned to row.created_at
|
||||
}
|
||||
|
||||
// ChatAttachment mirrors canvas ChatAttachment / ParsedFilePart.
|
||||
// Size is *int64 (not int64) so JSON omits the field when unknown,
|
||||
// rather than emitting `"size": 0` which the renderer would interpret
|
||||
// as "zero-byte file."
|
||||
type ChatAttachment struct {
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// ListOptions is the page-window the handler hands to the store.
|
||||
// Constructed by the handler from query parameters; the store should
|
||||
// not inspect the request directly.
|
||||
type ListOptions struct {
|
||||
// Limit is the page size. Caller (the handler) clamps to a sane
|
||||
// bound (default 100, max 1000); store treats Limit ≤ 0 as a
|
||||
// programming error.
|
||||
Limit int
|
||||
|
||||
// BeforeTS is the cursor for paginating backward. The store MUST
|
||||
// only consider this when HasBefore is true; using a zero-time
|
||||
// fallback would silently exclude the legitimate epoch-start case.
|
||||
BeforeTS time.Time
|
||||
HasBefore bool
|
||||
}
|
||||
|
||||
// MessageStore is the read-side interface. Implementations pluggable
|
||||
// via constructor injection at handler creation time.
|
||||
//
|
||||
// Why "List" and not "GetMessages" / "ReadHistory" / etc: List matches
|
||||
// the verb on /workspaces/:id/chat-history (HTTP GET on a collection)
|
||||
// and the existing handler method. One-name-one-thing keeps the
|
||||
// interface and the route lined up.
|
||||
type MessageStore interface {
|
||||
List(ctx context.Context, workspaceID string, opts ListOptions) (messages []ChatMessage, reachedEnd bool, err error)
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package messagestore
|
||||
|
||||
// postgres_store.go — default MessageStore impl that wraps today's
|
||||
// activity_logs query + the A2A-envelope parser ported in PR-C.
|
||||
//
|
||||
// Behavior is byte-identical to the pre-PR-D ChatHistoryHandler:
|
||||
// same SQL, same role-decision rules, same v0/v1 wire-shape support.
|
||||
// The only structural change is that the handler now depends on an
|
||||
// interface; this file is what was the pre-PR-D handler internals.
|
||||
//
|
||||
// This is the baseline impl OSS operators compare against when
|
||||
// writing alternative stores. Read it as the contract spec.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PostgresMessageStore is the platform-default impl. It queries the
|
||||
// activity_logs table directly and parses request_body / response_body
|
||||
// JSONB columns into ChatMessage values.
|
||||
type PostgresMessageStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPostgresMessageStore wraps a *sql.DB. The store does not own the
|
||||
// pool — closing it is the caller's responsibility.
|
||||
func NewPostgresMessageStore(db *sql.DB) *PostgresMessageStore {
|
||||
return &PostgresMessageStore{db: db}
|
||||
}
|
||||
|
||||
// internalSelfPrefixes — message texts that should be filtered from
|
||||
// chat history because they're internal self-triggers (heartbeats,
|
||||
// scheduled-task self-fire, delegation-result self-notify), not
|
||||
// user-typed messages. Mirrors canvas isInternalSelfMessage.
|
||||
//
|
||||
// Centralizing here means a future internal-trigger pattern is added
|
||||
// in one place; alternative impls of MessageStore are expected to
|
||||
// apply the same filter (or override deliberately).
|
||||
var internalSelfPrefixes = []string{
|
||||
"Delegation results are ready",
|
||||
}
|
||||
|
||||
// IsInternalSelfMessage reports whether text starts with any registered
|
||||
// internal-self prefix. Empty text returns false (legitimate
|
||||
// attachments-only bubble). Exported for impls that want to share the
|
||||
// same predicate.
|
||||
func IsInternalSelfMessage(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range internalSelfPrefixes {
|
||||
if strings.HasPrefix(text, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// List implements MessageStore. Newest-first, optionally paged by
|
||||
// BeforeTS. Filters to a2a_receive activity rows from the canvas
|
||||
// (source_id IS NULL) — same scope canvas applies via
|
||||
// /activity?source=canvas, centralized so future API consumers don't
|
||||
// need to know it.
|
||||
func (s *PostgresMessageStore) List(ctx context.Context, workspaceID string, opts ListOptions) ([]ChatMessage, bool, error) {
|
||||
if opts.Limit <= 0 {
|
||||
// Caller bug. Programmers learn quickly when the store
|
||||
// fails fast on bad opts; a silent clamp would hide the bug.
|
||||
return nil, true, errInvalidLimit
|
||||
}
|
||||
|
||||
rows, err := s.queryActivityRows(ctx, workspaceID, opts)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []ChatMessage
|
||||
rowCount := 0
|
||||
for rows.Next() {
|
||||
var (
|
||||
createdAt time.Time
|
||||
status string
|
||||
rawRequest sql.NullString
|
||||
rawResponse sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&createdAt, &status, &rawRequest, &rawResponse); err != nil {
|
||||
// Skip malformed row, continue. The error is logged at
|
||||
// the caller (handler) layer; an isolated bad row should
|
||||
// not abort the whole page.
|
||||
continue
|
||||
}
|
||||
rowCount++
|
||||
var requestBody, responseBody json.RawMessage
|
||||
if rawRequest.Valid {
|
||||
requestBody = json.RawMessage(rawRequest.String)
|
||||
}
|
||||
if rawResponse.Valid {
|
||||
responseBody = json.RawMessage(rawResponse.String)
|
||||
}
|
||||
messages = append(messages, activityRowToChatMessages(createdAt, status, requestBody, responseBody, IsInternalSelfMessage)...)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
reachedEnd := rowCount < opts.Limit
|
||||
return messages, reachedEnd, nil
|
||||
}
|
||||
|
||||
// queryActivityRows is split from List so unit tests can exercise the
|
||||
// parser without spinning a real DB. Internal — alternative impls
|
||||
// shouldn't depend on the SQL shape.
|
||||
func (s *PostgresMessageStore) queryActivityRows(ctx context.Context, workspaceID string, opts ListOptions) (*sql.Rows, error) {
|
||||
if opts.HasBefore {
|
||||
return s.db.QueryContext(ctx, `
|
||||
SELECT created_at, status, request_body::text, response_body::text
|
||||
FROM activity_logs
|
||||
WHERE workspace_id = $1
|
||||
AND activity_type = 'a2a_receive'
|
||||
AND source_id IS NULL
|
||||
AND created_at < $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
`, workspaceID, opts.BeforeTS, opts.Limit)
|
||||
}
|
||||
return s.db.QueryContext(ctx, `
|
||||
SELECT created_at, status, request_body::text, response_body::text
|
||||
FROM activity_logs
|
||||
WHERE workspace_id = $1
|
||||
AND activity_type = 'a2a_receive'
|
||||
AND source_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`, workspaceID, opts.Limit)
|
||||
}
|
||||
|
||||
// errInvalidLimit is returned by List when opts.Limit ≤ 0.
|
||||
type sentinelError string
|
||||
|
||||
func (e sentinelError) Error() string { return string(e) }
|
||||
|
||||
const errInvalidLimit sentinelError = "messagestore: List opts.Limit must be > 0"
|
||||
|
||||
// activityRowToChatMessages converts ONE activity_logs row into 0-2
|
||||
// ChatMessages. Direct port of canvas activityRowToMessages.
|
||||
//
|
||||
// - Up to 1 user-side bubble from request_body, unless internal-self.
|
||||
// - Up to 1 agent-side bubble from response_body. Role is "system"
|
||||
// when status='error' OR text starts with "agent error" (case-
|
||||
// insensitive — matches canvas predicate exactly).
|
||||
//
|
||||
// Both bubbles MUST adopt row.created_at as their timestamp. This
|
||||
// pins the regression cover for the 2026-04-25 bubble-collapse bug.
|
||||
func activityRowToChatMessages(
|
||||
createdAt time.Time,
|
||||
status string,
|
||||
requestBody json.RawMessage,
|
||||
responseBody json.RawMessage,
|
||||
internalSelf func(string) bool,
|
||||
) []ChatMessage {
|
||||
var out []ChatMessage
|
||||
timestamp := createdAt.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
userText := extractRequestText(requestBody)
|
||||
userAttachments := extractFilesFromUserMessage(requestBody)
|
||||
if !internalSelf(userText) && (userText != "" || len(userAttachments) > 0) {
|
||||
out = append(out, ChatMessage{
|
||||
ID: newMessageID(),
|
||||
Role: "user",
|
||||
Content: userText,
|
||||
Attachments: userAttachments,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
if len(responseBody) > 0 {
|
||||
agentText := extractChatResponseText(responseBody)
|
||||
agentAttachments := extractFilesFromResponse(responseBody)
|
||||
if agentText != "" || len(agentAttachments) > 0 {
|
||||
role := "agent"
|
||||
if status == "error" || strings.HasPrefix(strings.ToLower(agentText), "agent error") {
|
||||
role = "system"
|
||||
}
|
||||
out = append(out, ChatMessage{
|
||||
ID: newMessageID(),
|
||||
Role: role,
|
||||
Content: agentText,
|
||||
Attachments: agentAttachments,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// extractRequestText pulls the user's typed text from
|
||||
// request_body.params.message.parts[0].text. Returns "" on any
|
||||
// malformed shape; callers pair with extractFilesFromUserMessage to
|
||||
// catch attachments-only bubbles.
|
||||
func extractRequestText(body json.RawMessage) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var env struct {
|
||||
Params struct {
|
||||
Message struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
} `json:"message"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, p := range env.Params.Message.Parts {
|
||||
if t, ok := p["text"].(string); ok && t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractFilesFromUserMessage(body json.RawMessage) []ChatAttachment {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
var env struct {
|
||||
Params struct {
|
||||
Message json.RawMessage `json:"message"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(env.Params.Message) == 0 {
|
||||
return nil
|
||||
}
|
||||
return extractFilesFromTask(env.Params.Message)
|
||||
}
|
||||
|
||||
// extractChatResponseText collects text from any of the response
|
||||
// shapes canvas extractResponseText handles, joining with "\n":
|
||||
//
|
||||
// - {"result": "<text>"}
|
||||
// - {"result": {"parts": [{"kind":"text","text":""}]}}
|
||||
// - {"parts": [{"root": {"text": "..."}}]} (older nested)
|
||||
// - {"result": {"artifacts": [{"parts": [...]}]}} (task shape)
|
||||
// - {"task": "<text>"} (fallback)
|
||||
//
|
||||
// Why collect rather than first-source-wins: claude-code emits
|
||||
// multiple text parts; hermes emits summary-in-parts +
|
||||
// details-in-artifacts. The pre-collect first-wins silently
|
||||
// truncated 15k-char briefs and dropped artifact details.
|
||||
func extractChatResponseText(body json.RawMessage) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
// {"result": "string"}
|
||||
var asString struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &asString); err == nil && asString.Result != "" {
|
||||
return asString.Result
|
||||
}
|
||||
// {"result": {object}} — try the structured shapes
|
||||
var asObject struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &asObject); err != nil {
|
||||
return ""
|
||||
}
|
||||
var collected []string
|
||||
if len(asObject.Result) > 0 {
|
||||
var resultObj struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
Artifacts []json.RawMessage `json:"artifacts"`
|
||||
}
|
||||
if err := json.Unmarshal(asObject.Result, &resultObj); err == nil {
|
||||
if t := joinTextParts(resultObj.Parts); t != "" {
|
||||
collected = append(collected, t)
|
||||
}
|
||||
var rootTexts []string
|
||||
for _, p := range resultObj.Parts {
|
||||
if root, ok := p["root"].(map[string]any); ok {
|
||||
if t, ok := root["text"].(string); ok && t != "" {
|
||||
rootTexts = append(rootTexts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rootTexts) > 0 {
|
||||
collected = append(collected, strings.Join(rootTexts, "\n"))
|
||||
}
|
||||
for _, raw := range resultObj.Artifacts {
|
||||
var art struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &art); err == nil {
|
||||
if t := joinTextParts(art.Parts); t != "" {
|
||||
collected = append(collected, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(collected) > 0 {
|
||||
return strings.Join(collected, "\n")
|
||||
}
|
||||
if asObject.Task != "" {
|
||||
return asObject.Task
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func joinTextParts(parts []map[string]any) string {
|
||||
var texts []string
|
||||
for _, p := range parts {
|
||||
isText := false
|
||||
if k, ok := p["kind"].(string); ok && k == "text" {
|
||||
isText = true
|
||||
}
|
||||
if t, ok := p["type"].(string); ok && t == "text" {
|
||||
isText = true
|
||||
}
|
||||
if !isText {
|
||||
continue
|
||||
}
|
||||
if t, ok := p["text"].(string); ok && t != "" {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
func extractFilesFromResponse(body json.RawMessage) []ChatAttachment {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
var probe struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &probe)
|
||||
feed := body
|
||||
if len(probe.Result) > 0 {
|
||||
trimmed := bytesTrimSpace(probe.Result)
|
||||
if len(trimmed) > 0 && trimmed[0] == '{' {
|
||||
feed = probe.Result
|
||||
}
|
||||
}
|
||||
return extractFilesFromTask(feed)
|
||||
}
|
||||
|
||||
// extractFilesFromTask walks parts[] + artifacts[].parts[] +
|
||||
// status.message.parts[] + message.parts[]. Mirrors canvas
|
||||
// extractFilesFromTask exactly — same v0 hot path + v1 protobuf
|
||||
// flat shape.
|
||||
func extractFilesFromTask(taskJSON json.RawMessage) []ChatAttachment {
|
||||
if len(taskJSON) == 0 {
|
||||
return nil
|
||||
}
|
||||
var task struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
Artifacts []json.RawMessage `json:"artifacts"`
|
||||
Status json.RawMessage `json:"status"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(taskJSON, &task); err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []ChatAttachment
|
||||
out = appendFilesFromParts(out, task.Parts)
|
||||
for _, raw := range task.Artifacts {
|
||||
var art struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &art); err == nil {
|
||||
out = appendFilesFromParts(out, art.Parts)
|
||||
}
|
||||
}
|
||||
if len(task.Status) > 0 {
|
||||
var st struct {
|
||||
Message struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(task.Status, &st); err == nil {
|
||||
out = appendFilesFromParts(out, st.Message.Parts)
|
||||
}
|
||||
}
|
||||
if len(task.Message) > 0 {
|
||||
var msg struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
}
|
||||
if err := json.Unmarshal(task.Message, &msg); err == nil {
|
||||
out = appendFilesFromParts(out, msg.Parts)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendFilesFromParts(out []ChatAttachment, parts []map[string]any) []ChatAttachment {
|
||||
for _, raw := range parts {
|
||||
v0 := false
|
||||
if k, ok := raw["kind"].(string); ok && k == "file" {
|
||||
v0 = true
|
||||
}
|
||||
if t, ok := raw["type"].(string); ok && t == "file" {
|
||||
v0 = true
|
||||
}
|
||||
v1URL, _ := raw["url"].(string)
|
||||
if !v0 && v1URL == "" {
|
||||
continue
|
||||
}
|
||||
var att ChatAttachment
|
||||
if v0 {
|
||||
file, _ := raw["file"].(map[string]any)
|
||||
if file == nil {
|
||||
file = raw
|
||||
}
|
||||
uri, _ := file["uri"].(string)
|
||||
if uri == "" {
|
||||
continue
|
||||
}
|
||||
att.URI = uri
|
||||
if name, _ := file["name"].(string); name != "" {
|
||||
att.Name = name
|
||||
} else {
|
||||
att.Name = basename(uri)
|
||||
}
|
||||
if mt, ok := file["mimeType"].(string); ok {
|
||||
att.MimeType = mt
|
||||
}
|
||||
if sz, ok := numericSize(file["size"]); ok {
|
||||
att.Size = &sz
|
||||
}
|
||||
} else {
|
||||
att.URI = v1URL
|
||||
if name, _ := raw["filename"].(string); name != "" {
|
||||
att.Name = name
|
||||
} else {
|
||||
att.Name = basename(v1URL)
|
||||
}
|
||||
if mt, ok := raw["mediaType"].(string); ok {
|
||||
att.MimeType = mt
|
||||
}
|
||||
}
|
||||
out = append(out, att)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numericSize(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func basename(uri string) string {
|
||||
cleaned := strings.TrimPrefix(uri, "workspace:")
|
||||
cleaned = strings.TrimPrefix(cleaned, "https://")
|
||||
cleaned = strings.TrimPrefix(cleaned, "http://")
|
||||
if cleaned == "" {
|
||||
return "file"
|
||||
}
|
||||
return path.Base(cleaned)
|
||||
}
|
||||
|
||||
func bytesTrimSpace(b json.RawMessage) json.RawMessage {
|
||||
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t' || b[0] == '\n' || b[0] == '\r') {
|
||||
b = b[1:]
|
||||
}
|
||||
for len(b) > 0 && (b[len(b)-1] == ' ' || b[len(b)-1] == '\t' || b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func newMessageID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// Compile-time assertion: PostgresMessageStore satisfies MessageStore.
|
||||
// Catches any future drift between interface and impl at build time.
|
||||
var _ MessageStore = (*PostgresMessageStore)(nil)
|
||||
@@ -0,0 +1,422 @@
|
||||
package messagestore
|
||||
|
||||
// postgres_store_test.go — parser-level parity tests against the
|
||||
// canvas TS test fixtures in
|
||||
// canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts.
|
||||
//
|
||||
// Originally lived in handlers/chat_history_test.go (RFC #2945 PR-C);
|
||||
// PR-D moved them here when the parser was extracted to this package.
|
||||
// Every test case in the TS file has a Go counterpart, named after
|
||||
// the TS describe/it block.
|
||||
//
|
||||
// Mutation guidance: when adding behavior, add the case to BOTH
|
||||
// historyHydration.test.ts AND this file. The canvas TS is the
|
||||
// legacy source the server replaces; divergence == regression.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const fixedTimestamp = "2026-04-25T18:00:00Z"
|
||||
|
||||
func mustParseTime(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
tt, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", s, err)
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
func neverInternal(_ string) bool { return false }
|
||||
|
||||
// =====================================================================
|
||||
// timestamp preservation (regression cover)
|
||||
//
|
||||
// The canvas bug that motivated extracting the helper: every reload
|
||||
// re-stamped historical bubbles to render-time. Pin row.created_at
|
||||
// adoption.
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_UserMessageTimestampPinsToCreatedAt(t *testing.T) {
|
||||
created := mustParseTime(t, "2026-04-25T18:00:00Z")
|
||||
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"hello from earlier today"}]}}}`)
|
||||
|
||||
msgs := activityRowToChatMessages(created, "ok", body, nil, neverInternal)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 user message, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "user" {
|
||||
t.Errorf("role=%q want user", msgs[0].Role)
|
||||
}
|
||||
if !strings.HasPrefix(msgs[0].Timestamp, "2026-04-25T18:00:00") {
|
||||
t.Errorf("user message timestamp %q does NOT pin to row.created_at — regression of the 2026-04-25 bubble-collapse bug", msgs[0].Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_AgentMessageTimestampPinsToCreatedAt(t *testing.T) {
|
||||
created := mustParseTime(t, "2026-04-25T18:05:00Z")
|
||||
body := json.RawMessage(`{"result":"agent reply"}`)
|
||||
|
||||
msgs := activityRowToChatMessages(created, "ok", nil, body, neverInternal)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 agent message, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "agent" {
|
||||
t.Errorf("role=%q want agent", msgs[0].Role)
|
||||
}
|
||||
if !strings.HasPrefix(msgs[0].Timestamp, "2026-04-25T18:05:00") {
|
||||
t.Errorf("agent message timestamp %q does NOT pin to row.created_at", msgs[0].Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_TwoRowsDistinctTimestamps(t *testing.T) {
|
||||
bodyA := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"first"}]}}}`)
|
||||
bodyB := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"second"}]}}}`)
|
||||
a := activityRowToChatMessages(mustParseTime(t, "2026-04-25T14:00:00Z"), "ok", bodyA, nil, neverInternal)
|
||||
b := activityRowToChatMessages(mustParseTime(t, "2026-04-25T21:01:58Z"), "ok", bodyB, nil, neverInternal)
|
||||
|
||||
if len(a) != 1 || len(b) != 1 {
|
||||
t.Fatalf("expected 1 message each; got %d and %d", len(a), len(b))
|
||||
}
|
||||
if a[0].Timestamp == b[0].Timestamp {
|
||||
t.Errorf("two distinct created_at values produced same timestamp: %q", a[0].Timestamp)
|
||||
}
|
||||
if !strings.HasPrefix(a[0].Timestamp, "2026-04-25T14:00:00") || !strings.HasPrefix(b[0].Timestamp, "2026-04-25T21:01:58") {
|
||||
t.Errorf("timestamps drifted: a=%q b=%q", a[0].Timestamp, b[0].Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// user-message extraction
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_EmitsUserMessageWhenRequestHasText(t *testing.T) {
|
||||
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"hi agent"}]}}}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "user" || msgs[0].Content != "hi agent" {
|
||||
t.Errorf("role=%q content=%q want user/hi agent", msgs[0].Role, msgs[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_DropsInternalSelfMessages(t *testing.T) {
|
||||
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"Delegation results are ready..."}]}}}`)
|
||||
predicate := func(t string) bool { return strings.HasPrefix(t, "Delegation results are ready") }
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, predicate)
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" {
|
||||
t.Errorf("internal-self message rendered as user bubble: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_NoUserMessageWhenRequestBodyNull(t *testing.T) {
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, neverInternal)
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" {
|
||||
t.Errorf("emitted user bubble despite null request_body: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_UserAttachmentsHydratedFromRequestBody(t *testing.T) {
|
||||
body := json.RawMessage(`{
|
||||
"params": {
|
||||
"message": {
|
||||
"parts": [
|
||||
{"kind":"text","text":"here's the screenshot"},
|
||||
{"kind":"file","file":{"name":"shot.png","mimeType":"image/png","uri":"workspace:/uploads/shot.png","size":4096}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
|
||||
var user *ChatMessage
|
||||
for i := range msgs {
|
||||
if msgs[i].Role == "user" {
|
||||
user = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatalf("no user bubble produced")
|
||||
}
|
||||
if user.Content != "here's the screenshot" {
|
||||
t.Errorf("content=%q", user.Content)
|
||||
}
|
||||
if len(user.Attachments) != 1 {
|
||||
t.Fatalf("attachments=%d want 1", len(user.Attachments))
|
||||
}
|
||||
att := user.Attachments[0]
|
||||
if att.Name != "shot.png" || att.URI != "workspace:/uploads/shot.png" || att.MimeType != "image/png" {
|
||||
t.Errorf("attachment shape wrong: %+v", att)
|
||||
}
|
||||
if att.Size == nil || *att.Size != 4096 {
|
||||
t.Errorf("size=%v want 4096", att.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_AttachmentsOnlyUserBubbleWhenTextEmpty(t *testing.T) {
|
||||
// Drag-drop a file with no caption — bubble should still render.
|
||||
body := json.RawMessage(`{
|
||||
"params": {
|
||||
"message": {
|
||||
"parts": [
|
||||
{"kind":"file","file":{"name":"report.pdf","uri":"workspace:/uploads/report.pdf"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 attachments-only bubble, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "user" || msgs[0].Content != "" || len(msgs[0].Attachments) != 1 {
|
||||
t.Errorf("unexpected: role=%q content=%q attachments=%d", msgs[0].Role, msgs[0].Content, len(msgs[0].Attachments))
|
||||
}
|
||||
if msgs[0].Attachments[0].Name != "report.pdf" {
|
||||
t.Errorf("attachment name=%q want report.pdf", msgs[0].Attachments[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_InternalSelfPredicateSuppressesEvenWithAttachments(t *testing.T) {
|
||||
body := json.RawMessage(`{
|
||||
"params": {
|
||||
"message": {
|
||||
"parts": [
|
||||
{"kind":"text","text":"Delegation results are ready..."},
|
||||
{"kind":"file","file":{"name":"x.zip","uri":"workspace:/x.zip"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
predicate := func(t string) bool { return strings.HasPrefix(t, "Delegation results are ready") }
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, predicate)
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" {
|
||||
t.Errorf("internal-self predicate did NOT suppress user bubble despite attachments: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// agent-message extraction
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_AgentMessageFromResultString(t *testing.T) {
|
||||
body := json.RawMessage(`{"result":"agent says hi"}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
if len(msgs) != 1 || msgs[0].Role != "agent" || msgs[0].Content != "agent says hi" {
|
||||
t.Errorf("got %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_RoleSystemWhenStatusError(t *testing.T) {
|
||||
body := json.RawMessage(`{"result":"delegation failed"}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "error", nil, body, neverInternal)
|
||||
if len(msgs) != 1 || msgs[0].Role != "system" {
|
||||
t.Errorf("status=error did NOT promote role to system: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_RoleSystemWhenAgentErrorPrefix(t *testing.T) {
|
||||
// Defense-in-depth — if a runtime returns ok status but the text
|
||||
// itself starts with "agent error", the canvas would still
|
||||
// render system role. Mirror that here.
|
||||
body := json.RawMessage(`{"result":"Agent error: ProcessError(exit=1)"}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
if len(msgs) != 1 || msgs[0].Role != "system" {
|
||||
t.Errorf("agent-error prefix did NOT promote to system: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_AgentAttachmentsFromResponseBodyParts(t *testing.T) {
|
||||
// Notify shape: response_body = {"result":"<text>","parts":[{"kind":"file",...}]}
|
||||
body := json.RawMessage(`{
|
||||
"result": "Done — see attached.",
|
||||
"parts": [
|
||||
{"kind":"file","file":{"name":"build.zip","uri":"workspace:/tmp/build.zip","size":12345}}
|
||||
]
|
||||
}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
var agent *ChatMessage
|
||||
for i := range msgs {
|
||||
if msgs[i].Role == "agent" {
|
||||
agent = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if agent == nil {
|
||||
t.Fatalf("no agent bubble")
|
||||
}
|
||||
if len(agent.Attachments) != 1 || agent.Attachments[0].Name != "build.zip" {
|
||||
t.Errorf("agent attachments shape wrong: %+v", agent.Attachments)
|
||||
}
|
||||
if agent.Attachments[0].Size == nil || *agent.Attachments[0].Size != 12345 {
|
||||
t.Errorf("size=%v want 12345", agent.Attachments[0].Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_NoAgentMessageWhenResponseBodyNull(t *testing.T) {
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, neverInternal)
|
||||
for _, m := range msgs {
|
||||
if m.Role == "agent" || m.Role == "system" {
|
||||
t.Errorf("emitted agent/system bubble despite null response_body: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_NoAgentMessageWhenResponseHasNoTextNoFiles(t *testing.T) {
|
||||
body := json.RawMessage(`{"unrelated":"metadata"}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
for _, m := range msgs {
|
||||
if m.Role == "agent" {
|
||||
t.Errorf("emitted agent bubble despite empty content: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// end-to-end shape — paired user + agent with same timestamp
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_PairedUserAndAgentSameTimestamp(t *testing.T) {
|
||||
created := mustParseTime(t, "2026-04-25T18:00:00Z")
|
||||
req := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"what's 2+2?"}]}}}`)
|
||||
resp := json.RawMessage(`{"result":"4"}`)
|
||||
msgs := activityRowToChatMessages(created, "ok", req, resp, neverInternal)
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "user" || msgs[0].Content != "what's 2+2?" {
|
||||
t.Errorf("first message wrong: %+v", msgs[0])
|
||||
}
|
||||
if msgs[1].Role != "agent" || msgs[1].Content != "4" {
|
||||
t.Errorf("second message wrong: %+v", msgs[1])
|
||||
}
|
||||
if msgs[0].Timestamp != msgs[1].Timestamp {
|
||||
t.Errorf("paired bubbles have different timestamps: %q vs %q", msgs[0].Timestamp, msgs[1].Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Go-specific: defensive parsing
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_MalformedJSONInRequestBodyReturnsEmpty(t *testing.T) {
|
||||
// Should NOT panic; should return no user bubble (or no message at all).
|
||||
body := json.RawMessage(`{not valid json}`)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("panic on malformed json: %v", r)
|
||||
}
|
||||
}()
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" && (m.Content != "" || len(m.Attachments) > 0) {
|
||||
t.Errorf("malformed JSON yielded a non-empty user bubble: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_V1ProtobufFlatFileShape(t *testing.T) {
|
||||
// v1 a2a-sdk shape: flat parts with url/filename/mediaType
|
||||
body := json.RawMessage(`{
|
||||
"result": {
|
||||
"parts": [
|
||||
{"url":"https://example.com/data.csv","filename":"data.csv","mediaType":"text/csv"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
var agent *ChatMessage
|
||||
for i := range msgs {
|
||||
if msgs[i].Role == "agent" {
|
||||
agent = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if agent == nil {
|
||||
t.Fatalf("no agent bubble for v1 shape")
|
||||
}
|
||||
if len(agent.Attachments) != 1 {
|
||||
t.Fatalf("attachments=%d want 1", len(agent.Attachments))
|
||||
}
|
||||
att := agent.Attachments[0]
|
||||
if att.Name != "data.csv" || att.URI != "https://example.com/data.csv" || att.MimeType != "text/csv" {
|
||||
t.Errorf("v1 shape extracted wrong: %+v", att)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_TaskShapeArtifactsExtracted(t *testing.T) {
|
||||
// {"result":{"artifacts":[{"parts":[{"kind":"text","text":"..."}]}]}}
|
||||
body := json.RawMessage(`{
|
||||
"result": {
|
||||
"artifacts": [
|
||||
{"parts": [{"kind":"text","text":"hermes detail line"}]}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
if len(msgs) != 1 || msgs[0].Content != "hermes detail line" {
|
||||
t.Errorf("artifact text not extracted: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHistory_OlderNestedRootTextShape(t *testing.T) {
|
||||
// Older shape: {parts: [{root: {text: "..."}}]}
|
||||
body := json.RawMessage(`{
|
||||
"result": {
|
||||
"parts": [{"root":{"text":"legacy nested text"}}]
|
||||
}
|
||||
}`)
|
||||
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
|
||||
if len(msgs) != 1 || !strings.Contains(msgs[0].Content, "legacy nested text") {
|
||||
t.Errorf("nested root.text not extracted: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// IsInternalSelfMessage predicate itself
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_IsInternalSelfMessage_DelegationPrefix(t *testing.T) {
|
||||
if !IsInternalSelfMessage("Delegation results are ready... <body>") {
|
||||
t.Errorf("Delegation-results prefix should be flagged internal-self")
|
||||
}
|
||||
if IsInternalSelfMessage("Delegation completed but not ready") {
|
||||
t.Errorf("non-prefix match should NOT flag")
|
||||
}
|
||||
if IsInternalSelfMessage("") {
|
||||
t.Errorf("empty text should NOT flag (legitimate attachments-only bubble)")
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// basename helper — mirrors canvas basename() semantics
|
||||
// =====================================================================
|
||||
|
||||
func TestChatHistory_BasenameStripsSchemeAndPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"workspace:/uploads/shot.png", "shot.png"},
|
||||
{"workspace:/a/b/c/file.txt", "file.txt"},
|
||||
{"https://example.com/path/file.csv", "file.csv"},
|
||||
{"http://x/y", "y"},
|
||||
{"", "file"},
|
||||
{"workspace:", "file"}, // scheme-only collapses to "" → "file" sentinel, matches canvas basename
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := basename(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("basename(%q) = %q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,18 @@ type Storage interface {
|
||||
// the whole batch succeeds or the user re-uploads.
|
||||
PutBatch(ctx context.Context, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error)
|
||||
|
||||
// PutBatchTx is the Tx-aware variant of PutBatch. It runs its INSERTs
|
||||
// inside the caller-provided tx so multi-file uploads can commit
|
||||
// atomically with sibling writes (e.g. activity_logs rows in
|
||||
// chat_files uploadPollMode). Pre-input validation runs before any
|
||||
// DB work; on validation failure no INSERT is issued.
|
||||
//
|
||||
// Caller owns the Tx lifecycle: BeginTx before, Commit/Rollback
|
||||
// after. PutBatchTx does NOT call Commit — a successful return only
|
||||
// means the inserts queued cleanly inside the Tx. The caller's
|
||||
// Commit is what actually persists the rows.
|
||||
PutBatchTx(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error)
|
||||
|
||||
// Get returns the full row including content. Returns ErrNotFound
|
||||
// when the row is absent, acked, or past expires_at. Caller should
|
||||
// not differentiate the three cases in the response — from the
|
||||
@@ -207,19 +219,8 @@ func (p *PostgresStorage) PutBatch(ctx context.Context, workspaceID uuid.UUID, i
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for i, it := range items {
|
||||
if len(it.Content) == 0 {
|
||||
return nil, fmt.Errorf("pendinguploads: item %d: empty content", i)
|
||||
}
|
||||
if len(it.Content) > MaxFileBytes {
|
||||
return nil, ErrTooLarge
|
||||
}
|
||||
if it.Filename == "" {
|
||||
return nil, fmt.Errorf("pendinguploads: item %d: empty filename", i)
|
||||
}
|
||||
if len(it.Filename) > 100 {
|
||||
return nil, fmt.Errorf("pendinguploads: item %d: filename exceeds 100 chars", i)
|
||||
}
|
||||
if err := validatePutBatchItems(items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
@@ -232,6 +233,53 @@ func (p *PostgresStorage) PutBatch(ctx context.Context, workspaceID uuid.UUID, i
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
out, err := putBatchInsertRows(ctx, tx, workspaceID, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("pendinguploads: commit batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PutBatchTx runs the same INSERT sequence as PutBatch but inside the
|
||||
// caller's tx. The caller is responsible for Commit/Rollback. Pre-input
|
||||
// validation still happens; on validation failure the tx is left in
|
||||
// whatever state it had (the caller will typically Rollback). On a
|
||||
// per-row INSERT error the caller MUST Rollback — pending_uploads rows
|
||||
// already inserted in this tx (rows 0..i-1) are not yet visible and
|
||||
// disappear with the rollback.
|
||||
func (p *PostgresStorage) PutBatchTx(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validatePutBatchItems(items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return putBatchInsertRows(ctx, tx, workspaceID, items)
|
||||
}
|
||||
|
||||
func validatePutBatchItems(items []PutItem) error {
|
||||
for i, it := range items {
|
||||
if len(it.Content) == 0 {
|
||||
return fmt.Errorf("pendinguploads: item %d: empty content", i)
|
||||
}
|
||||
if len(it.Content) > MaxFileBytes {
|
||||
return ErrTooLarge
|
||||
}
|
||||
if it.Filename == "" {
|
||||
return fmt.Errorf("pendinguploads: item %d: empty filename", i)
|
||||
}
|
||||
if len(it.Filename) > 100 {
|
||||
return fmt.Errorf("pendinguploads: item %d: filename exceeds 100 chars", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func putBatchInsertRows(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error) {
|
||||
out := make([]uuid.UUID, 0, len(items))
|
||||
for i, it := range items {
|
||||
var fid uuid.UUID
|
||||
@@ -245,10 +293,6 @@ func (p *PostgresStorage) PutBatch(ctx context.Context, workspaceID uuid.UUID, i
|
||||
}
|
||||
out = append(out, fid)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("pendinguploads: commit batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -731,3 +731,138 @@ func TestPutBatch_CommitError_Wrapped(t *testing.T) {
|
||||
t.Errorf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- PutBatchTx ----------------------------------------------------------
|
||||
//
|
||||
// PutBatchTx is the Tx-aware variant added in #149 so chat_files
|
||||
// uploadPollMode can commit pending_uploads + activity_logs atomically
|
||||
// in one Tx. Pre-validation is shared with PutBatch (extracted into
|
||||
// validatePutBatchItems); these tests pin the contract that PutBatchTx
|
||||
// runs INSERTs in the caller's tx and never calls Begin/Commit itself.
|
||||
|
||||
func TestPutBatchTx_HappyPath_RowsInsertedInTx_NoCommitFromHere(t *testing.T) {
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
|
||||
wsID := uuid.New()
|
||||
id1, id2 := uuid.New(), uuid.New()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(insertSQL).
|
||||
WithArgs(wsID, []byte("aaa"), int64(3), "a.txt", "text/plain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"file_id"}).AddRow(id1))
|
||||
mock.ExpectQuery(insertSQL).
|
||||
WithArgs(wsID, []byte("bbbb"), int64(4), "b.bin", "application/octet-stream").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"file_id"}).AddRow(id2))
|
||||
mock.ExpectCommit()
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.PutBatchTx(context.Background(), tx, wsID, []pendinguploads.PutItem{
|
||||
{Content: []byte("aaa"), Filename: "a.txt", Mimetype: "text/plain"},
|
||||
{Content: []byte("bbbb"), Filename: "b.bin", Mimetype: "application/octet-stream"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutBatchTx: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Errorf("ids out of order: got %v want [%s %s]", got, id1, id2)
|
||||
}
|
||||
// Caller is responsible for Commit — PutBatchTx must NOT have called it.
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("caller Commit: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutBatchTx_EmptyItems_NoDBWork(t *testing.T) {
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
|
||||
// No expectations — PutBatchTx with empty items must short-circuit
|
||||
// before any tx interaction.
|
||||
got, err := store.PutBatchTx(context.Background(), nil, uuid.New(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on empty batch, got %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil ids on empty batch, got %v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutBatchTx_ValidationFails_NoTxQuery(t *testing.T) {
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
|
||||
// Caller opens the Tx; PutBatchTx must reject the invalid item
|
||||
// before issuing any tx.QueryRowContext. Rollback comes from the
|
||||
// caller's defer, not from PutBatchTx.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectRollback()
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.PutBatchTx(context.Background(), tx, uuid.New(), []pendinguploads.PutItem{
|
||||
{Content: []byte("hi"), Filename: ""},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty filename") {
|
||||
t.Fatalf("expected empty-filename error, got %v", err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutBatchTx_PerRowErrorPropagates_CallerRollsBack(t *testing.T) {
|
||||
// PutBatchTx returns an error on per-row INSERT failure but does
|
||||
// NOT call Rollback itself — that's the caller's job. This pins
|
||||
// the Tx-lifecycle ownership contract: the caller controls Begin
|
||||
// and Rollback/Commit, PutBatchTx only runs INSERTs.
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
|
||||
wsID := uuid.New()
|
||||
id1 := uuid.New()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(insertSQL).
|
||||
WithArgs(wsID, []byte("ok"), int64(2), "a.txt", "text/plain").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"file_id"}).AddRow(id1))
|
||||
mock.ExpectQuery(insertSQL).
|
||||
WithArgs(wsID, []byte("xx"), int64(2), "b.txt", "text/plain").
|
||||
WillReturnError(errors.New("connection lost mid-insert"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.PutBatchTx(context.Background(), tx, wsID, []pendinguploads.PutItem{
|
||||
{Content: []byte("ok"), Filename: "a.txt", Mimetype: "text/plain"},
|
||||
{Content: []byte("xx"), Filename: "b.txt", Mimetype: "text/plain"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "batch insert item 1") {
|
||||
t.Fatalf("expected wrapped item-1 error, got %v", err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatalf("caller Rollback: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package pendinguploads_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -47,6 +48,9 @@ func (f *fakeSweepStorage) Ack(_ context.Context, _ uuid.UUID) error {
|
||||
func (f *fakeSweepStorage) PutBatch(_ context.Context, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
|
||||
return nil, errors.New("not used")
|
||||
}
|
||||
func (f *fakeSweepStorage) PutBatchTx(_ context.Context, _ *sql.Tx, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
|
||||
return nil, errors.New("not used")
|
||||
}
|
||||
func (f *fakeSweepStorage) Sweep(_ context.Context, ackRetention time.Duration) (pendinguploads.SweepResult, error) {
|
||||
idx := int(f.calls.Load())
|
||||
f.calls.Add(1)
|
||||
|
||||
@@ -35,36 +35,37 @@ import (
|
||||
// drift-risk #6.
|
||||
var ErrNoBackend = errors.New("provisioner: no backend configured (zero-valued receiver)")
|
||||
|
||||
// RuntimeImages maps runtime names to their Docker image refs on GHCR.
|
||||
// RuntimeImages maps runtime names to their Docker image refs.
|
||||
// Each standalone template repo publishes its image via the reusable
|
||||
// publish-template-image workflow in molecule-ci on every main merge.
|
||||
// The provisioner pulls these on demand (see ensureImageLocal) — no
|
||||
// pre-build step on the tenant host.
|
||||
//
|
||||
// The registry prefix is determined by RegistryPrefix() in registry.go;
|
||||
// defaults to ghcr.io/molecule-ai (upstream OSS) and is overridden via the
|
||||
// MOLECULE_IMAGE_REGISTRY env var in production tenants that mirror to
|
||||
// AWS ECR or another registry. The map is computed at package init and
|
||||
// captures whatever prefix was active then.
|
||||
//
|
||||
// Legacy local-build path (`docker build -t workspace-template:<runtime>`
|
||||
// via scripts/build-images.sh) is still supported for development:
|
||||
// when a bare `workspace-template:<runtime>` image is present locally,
|
||||
// Docker's image resolver matches it before any pull is attempted. Set
|
||||
// the env var WORKSPACE_IMAGE_LOCAL_OVERRIDE=1 (enforced by callers) to
|
||||
// short-circuit pulls entirely if needed.
|
||||
var RuntimeImages = map[string]string{
|
||||
"langgraph": "ghcr.io/molecule-ai/workspace-template-langgraph:latest",
|
||||
"claude-code": "ghcr.io/molecule-ai/workspace-template-claude-code:latest",
|
||||
"openclaw": "ghcr.io/molecule-ai/workspace-template-openclaw:latest",
|
||||
"deepagents": "ghcr.io/molecule-ai/workspace-template-deepagents:latest",
|
||||
"crewai": "ghcr.io/molecule-ai/workspace-template-crewai:latest",
|
||||
"autogen": "ghcr.io/molecule-ai/workspace-template-autogen:latest",
|
||||
"hermes": "ghcr.io/molecule-ai/workspace-template-hermes:latest", // Hermes (Nous Research) — real hermes-agent behind A2A bridge
|
||||
"gemini-cli": "ghcr.io/molecule-ai/workspace-template-gemini-cli:latest", // Google Gemini CLI
|
||||
}
|
||||
var RuntimeImages = computeRuntimeImages()
|
||||
|
||||
// DefaultImage is the fallback workspace Docker image (langgraph is the
|
||||
// most common runtime). Computed via RegistryPrefix() so the prefix
|
||||
// override applies to the fallback path too.
|
||||
//
|
||||
// NOTE: Every runtime MUST have an entry in knownRuntimes (registry.go).
|
||||
// If a runtime is missing, it falls back to DefaultImage which may have
|
||||
// wrong deps. Add new runtimes to knownRuntimes AND create the standalone
|
||||
// template repo.
|
||||
var DefaultImage = RuntimeImage(defaultRuntime)
|
||||
|
||||
const (
|
||||
// DefaultImage is the fallback workspace Docker image (langgraph is the most common runtime).
|
||||
DefaultImage = "ghcr.io/molecule-ai/workspace-template-langgraph:latest"
|
||||
// NOTE: Every runtime MUST have an entry in RuntimeImages above. If a runtime is missing,
|
||||
// it falls back to DefaultImage which may have wrong deps. Add new runtimes to both
|
||||
// RuntimeImages AND create the standalone template repo.
|
||||
|
||||
// DefaultNetwork is the Docker network workspaces join.
|
||||
DefaultNetwork = "molecule-monorepo-net"
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// defaultRegistryPrefix is the upstream OSS face for all workspace template
|
||||
// images. Self-hosted Molecule deployments without the MOLECULE_IMAGE_REGISTRY
|
||||
// override pull from here.
|
||||
const defaultRegistryPrefix = "ghcr.io/molecule-ai"
|
||||
|
||||
// knownRuntimes is the canonical list of workspace template runtimes shipped
|
||||
// in main. Any runtime added here MUST also have a standalone template repo
|
||||
// (Molecule-AI/molecule-ai-workspace-template-<name>) and an entry in the
|
||||
// publish-template-image workflow that builds it.
|
||||
//
|
||||
// Order matters for deterministic test snapshots; keep alphabetical.
|
||||
var knownRuntimes = []string{
|
||||
"autogen",
|
||||
"claude-code",
|
||||
"codex",
|
||||
"crewai",
|
||||
"deepagents",
|
||||
"gemini-cli",
|
||||
"hermes",
|
||||
"langgraph",
|
||||
"openclaw",
|
||||
}
|
||||
|
||||
// defaultRuntime is the fallback when a workspace's config doesn't specify a
|
||||
// runtime. Picked because LangGraph is the most common in our org templates
|
||||
// and has the smallest "first impression" cold-start surface.
|
||||
const defaultRuntime = "langgraph"
|
||||
|
||||
// RegistryPrefix returns the registry prefix all workspace-template image
|
||||
// references should use. Defaults to ghcr.io/molecule-ai (the upstream OSS
|
||||
// face) and is overridden by the MOLECULE_IMAGE_REGISTRY env var in
|
||||
// production tenants where we mirror images to a private registry.
|
||||
//
|
||||
// The override is set at deploy time (Railway env, EC2 user-data) — never
|
||||
// from user-supplied input — so the value is trusted by the time it reaches
|
||||
// this code. Validation is deliberately minimal: an operator-supplied
|
||||
// prefix that points at a registry the EC2 can't authenticate to will fail
|
||||
// loudly at docker-pull time, which is the right blast radius.
|
||||
//
|
||||
// Example values:
|
||||
//
|
||||
// (unset) → ghcr.io/molecule-ai (OSS default)
|
||||
// "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai" → AWS ECR mirror
|
||||
// "git.moleculesai.app/molecule-ai" → self-hosted Gitea Container Registry (future)
|
||||
//
|
||||
// Auth is registry-specific and configured outside this function:
|
||||
// - GHCR: GHCR_USER/GHCR_TOKEN env vars consumed by ghcrAuthHeader()
|
||||
// - ECR: docker credential helper (amazon-ecr-credential-helper) configured
|
||||
// in EC2 user-data; ~/.docker/config.json has credHelpers entry; the
|
||||
// daemon resolves auth automatically on every pull.
|
||||
func RegistryPrefix() string {
|
||||
if v := os.Getenv("MOLECULE_IMAGE_REGISTRY"); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultRegistryPrefix
|
||||
}
|
||||
|
||||
// RuntimeImage returns the canonical image reference for the given runtime,
|
||||
// using the current RegistryPrefix() and the moving `:latest` tag.
|
||||
//
|
||||
// For SHA-pinned references (production thin-AMI launches), the
|
||||
// runtime_image_pins lookup in handlers/runtime_image_pin.go strips the
|
||||
// `:latest` suffix and appends an immutable `@sha256:<digest>` from the DB.
|
||||
// That code path naturally inherits any RegistryPrefix() change because it
|
||||
// reads from RuntimeImages[runtime] and only re-formats the tag suffix.
|
||||
//
|
||||
// Returns the empty string for unknown runtimes; callers should fall through
|
||||
// to DefaultImage in that case (matching legacy behavior).
|
||||
func RuntimeImage(runtime string) string {
|
||||
for _, r := range knownRuntimes {
|
||||
if r == runtime {
|
||||
return fmt.Sprintf("%s/workspace-template-%s:latest", RegistryPrefix(), runtime)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// computeRuntimeImages returns the {runtime: image-ref} map evaluated against
|
||||
// the current RegistryPrefix(). Called at package init to populate the
|
||||
// exported RuntimeImages var. Tests that flip MOLECULE_IMAGE_REGISTRY between
|
||||
// expected values use this helper to rebuild the map mid-run.
|
||||
func computeRuntimeImages() map[string]string {
|
||||
out := make(map[string]string, len(knownRuntimes))
|
||||
for _, r := range knownRuntimes {
|
||||
out[r] = RuntimeImage(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRegistryPrefix_DefaultsToGHCR pins the OSS-default behavior. If a future
|
||||
// refactor accidentally drops the default, OSS users self-hosting Molecule
|
||||
// would silently lose image pulls — this test should fail loudly instead.
|
||||
func TestRegistryPrefix_DefaultsToGHCR(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
got := RegistryPrefix()
|
||||
want := "ghcr.io/molecule-ai"
|
||||
if got != want {
|
||||
t.Fatalf("RegistryPrefix() = %q, want %q (default must remain GHCR for OSS users)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryPrefix_RespectsEnv verifies the override path used in
|
||||
// production tenants where MOLECULE_IMAGE_REGISTRY points at a private
|
||||
// mirror (AWS ECR, self-hosted Harbor, etc.).
|
||||
func TestRegistryPrefix_RespectsEnv(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai")
|
||||
got := RegistryPrefix()
|
||||
want := "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai"
|
||||
if got != want {
|
||||
t.Fatalf("RegistryPrefix() = %q, want %q (env override path is the production cutover mechanism)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryPrefix_EmptyEnvFallsBackToDefault — guard against an operator
|
||||
// setting MOLECULE_IMAGE_REGISTRY="" by mistake (e.g. unset deploy variable
|
||||
// becomes empty string, not literally absent). We treat "" as "use default"
|
||||
// so a misconfigured env doesn't mean an empty registry prefix.
|
||||
func TestRegistryPrefix_EmptyEnvFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
if RegistryPrefix() != defaultRegistryPrefix {
|
||||
t.Fatalf("empty MOLECULE_IMAGE_REGISTRY should fall back to %q, got %q", defaultRegistryPrefix, RegistryPrefix())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeImage_AllKnownRuntimes — every runtime in the canonical list
|
||||
// must produce a properly-formatted image ref. If a new runtime is added to
|
||||
// knownRuntimes but the format changes, this catches it.
|
||||
func TestRuntimeImage_AllKnownRuntimes(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
for _, r := range knownRuntimes {
|
||||
got := RuntimeImage(r)
|
||||
want := "ghcr.io/molecule-ai/workspace-template-" + r + ":latest"
|
||||
if got != want {
|
||||
t.Errorf("RuntimeImage(%q) = %q, want %q", r, got, want)
|
||||
}
|
||||
}
|
||||
// Pin the count so adding a runtime requires explicit test acknowledgement.
|
||||
if len(knownRuntimes) != 9 {
|
||||
t.Errorf("knownRuntimes length = %d, want 9 (autogen, claude-code, codex, crewai, deepagents, gemini-cli, hermes, langgraph, openclaw)", len(knownRuntimes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeImage_UnknownRuntime — defensive: callers must fall back to
|
||||
// DefaultImage when a runtime is unknown, never silently use the wrong
|
||||
// prefix. Returning "" enforces an explicit fallback at every call site.
|
||||
func TestRuntimeImage_UnknownRuntime(t *testing.T) {
|
||||
for _, name := range []string{"", "nonexistent", "WORKSPACE-TEMPLATE-FAKE", "../../../etc/passwd"} {
|
||||
if got := RuntimeImage(name); got != "" {
|
||||
t.Errorf("RuntimeImage(%q) = %q, want empty string for unknown runtime", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeImage_RegistryOverrideAppliesToAllRuntimes — the override
|
||||
// flips ALL runtimes consistently. If a refactor accidentally hardcoded
|
||||
// the prefix in some runtimes but not others (the failure mode that
|
||||
// triggered this whole rollout), this test catches it.
|
||||
func TestRuntimeImage_RegistryOverrideAppliesToAllRuntimes(t *testing.T) {
|
||||
const ecr = "999999999999.dkr.ecr.us-east-2.amazonaws.com/molecule-ai"
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", ecr)
|
||||
|
||||
for _, r := range knownRuntimes {
|
||||
got := RuntimeImage(r)
|
||||
if !strings.HasPrefix(got, ecr+"/workspace-template-") {
|
||||
t.Errorf("RuntimeImage(%q) = %q, must start with override prefix %q", r, got, ecr)
|
||||
}
|
||||
if !strings.HasSuffix(got, ":latest") {
|
||||
t.Errorf("RuntimeImage(%q) = %q, must keep :latest tag suffix", r, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeRuntimeImages_AllRuntimesPresent — the map must contain every
|
||||
// known runtime. Drift between knownRuntimes and computeRuntimeImages would
|
||||
// silently break the runtime → image lookup that provisioner.Start uses.
|
||||
func TestComputeRuntimeImages_AllRuntimesPresent(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
m := computeRuntimeImages()
|
||||
if len(m) != len(knownRuntimes) {
|
||||
t.Fatalf("computeRuntimeImages() has %d entries, want %d (one per knownRuntime)", len(m), len(knownRuntimes))
|
||||
}
|
||||
for _, r := range knownRuntimes {
|
||||
img, ok := m[r]
|
||||
if !ok {
|
||||
t.Errorf("computeRuntimeImages() missing runtime %q", r)
|
||||
continue
|
||||
}
|
||||
if img == "" {
|
||||
t.Errorf("computeRuntimeImages()[%q] is empty", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeRuntimeImages_ReflectsCurrentEnv — calling computeRuntimeImages
|
||||
// after env change rebuilds the map with new prefix. Tests + ops procedures
|
||||
// that flip the env in-process rely on this.
|
||||
func TestComputeRuntimeImages_ReflectsCurrentEnv(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
defaultMap := computeRuntimeImages()
|
||||
if !strings.HasPrefix(defaultMap["claude-code"], "ghcr.io/molecule-ai/") {
|
||||
t.Fatalf("default map should be GHCR-prefixed, got %q", defaultMap["claude-code"])
|
||||
}
|
||||
|
||||
const mirror = "registry.example.com/molecule-ai"
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", mirror)
|
||||
mirrorMap := computeRuntimeImages()
|
||||
if !strings.HasPrefix(mirrorMap["claude-code"], mirror+"/") {
|
||||
t.Fatalf("mirror-prefixed map should start with %q, got %q", mirror, mirrorMap["claude-code"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownRuntimes_AlphabeticalOrder — pin the order so test snapshots
|
||||
// (and human readers diffing the file) see deterministic output. Adding a
|
||||
// new runtime out of alphabetical order will fail this test, which is the
|
||||
// nudge to keep the file readable.
|
||||
func TestKnownRuntimes_AlphabeticalOrder(t *testing.T) {
|
||||
for i := 1; i < len(knownRuntimes); i++ {
|
||||
if knownRuntimes[i-1] >= knownRuntimes[i] {
|
||||
t.Errorf("knownRuntimes not alphabetical: %q comes before %q", knownRuntimes[i-1], knownRuntimes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
)
|
||||
|
||||
@@ -197,7 +198,7 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
|
||||
// A separate event type was considered but the UI reaction is
|
||||
// identical either way — operators who need to distinguish can
|
||||
// tell from the `source` payload field.
|
||||
if emitErr := emitter.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", c.id, map[string]interface{}{
|
||||
if emitErr := emitter.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), c.id, map[string]interface{}{
|
||||
"error": msg,
|
||||
"timeout_secs": timeoutSec,
|
||||
"runtime": c.runtime,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/messagestore"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
@@ -315,6 +316,18 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
wsAuth.POST("/activity", acth.Report)
|
||||
wsAuth.POST("/notify", acth.Notify)
|
||||
|
||||
// Chat history — RFC #2945 PR-C (issue #3017) + PR-D (issue
|
||||
// #3026). Server-side rendering of activity_logs rows into
|
||||
// the canonical ChatMessage shape; storage is plugin-shaped
|
||||
// via the messagestore.MessageStore interface so OSS
|
||||
// operators can swap in S3 / vector / in-memory backends
|
||||
// without forking the handler. Platform default uses
|
||||
// PostgresMessageStore wrapping the existing activity_logs
|
||||
// table.
|
||||
chatStore := messagestore.NewPostgresMessageStore(db.DB)
|
||||
chh := handlers.NewChatHistoryHandler(chatStore)
|
||||
wsAuth.GET("/chat-history", chh.List)
|
||||
|
||||
// Config
|
||||
cfgh := handlers.NewConfigHandler()
|
||||
wsAuth.GET("/config", cfgh.Get)
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
cronlib "github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/metrics"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -521,7 +523,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
||||
"schedule_id": sched.ID,
|
||||
"schedule_name": sched.Name,
|
||||
"cron_expr": sched.CronExpr,
|
||||
"prompt": sanitizeUTF8(truncate(sched.Prompt, 200)),
|
||||
"prompt": sanitizeUTF8(textutil.TruncateBytes(sched.Prompt, 200)),
|
||||
})
|
||||
// #152: persist lastError into error_detail on the activity_logs row
|
||||
// so GET /workspaces/:id/schedules/:id/history can surface why a run
|
||||
@@ -541,7 +543,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
||||
insertCancel()
|
||||
|
||||
if s.broadcaster != nil {
|
||||
s.broadcaster.RecordAndBroadcast(ctx, "CRON_EXECUTED", sched.WorkspaceID, map[string]interface{}{
|
||||
s.broadcaster.RecordAndBroadcast(ctx, string(events.EventCronExecuted), sched.WorkspaceID, map[string]interface{}{
|
||||
"schedule_id": sched.ID,
|
||||
"schedule_name": sched.Name,
|
||||
"status": lastStatus,
|
||||
@@ -618,7 +620,7 @@ func (s *Scheduler) recordSkipped(ctx context.Context, sched scheduleRow, active
|
||||
skipInsCancel()
|
||||
|
||||
if s.broadcaster != nil {
|
||||
_ = s.broadcaster.RecordAndBroadcast(ctx, "CRON_SKIPPED", sched.WorkspaceID, map[string]interface{}{
|
||||
_ = s.broadcaster.RecordAndBroadcast(ctx, string(events.EventCronSkipped), sched.WorkspaceID, map[string]interface{}{
|
||||
"schedule_id": sched.ID,
|
||||
"schedule_name": sched.Name,
|
||||
"reason": reason,
|
||||
@@ -806,27 +808,10 @@ func isEmptyResponse(body []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// truncate shortens s to at most maxLen bytes, appending "..." if truncated.
|
||||
// #2026: UTF-8 safe — byte-slicing at maxLen-3 would split multi-byte runes
|
||||
// (observed: U+2026 `…` = 0xe2 0x80 0xa6, sliced mid-char, concatenated with
|
||||
// "..." producing 0xe2 0x80 0x2e — rejected by Postgres as invalid UTF-8,
|
||||
// which wedged the activity_logs INSERT with no deadline and stalled the
|
||||
// scheduler).
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
cut := maxLen - 3
|
||||
if cut < 0 {
|
||||
cut = 0
|
||||
}
|
||||
// Back up to a rune boundary — utf8.RuneStart returns true for any
|
||||
// non-continuation byte (ASCII, or the lead byte of a multi-byte rune).
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
|
||||
// The original #2026 fix lives in textutil's package docs as canonical
|
||||
// prior art. Ellipsis was previously "..." (3 ASCII bytes); the SSOT
|
||||
// uses "…" (3 UTF-8 bytes) — same byte budget, single-glyph display.
|
||||
|
||||
// short returns up to n leading characters of s without panicking when s is
|
||||
// shorter than n. Used to safely display UUID prefixes in log lines where
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// errDBDown is a sentinel error used by tests to simulate a DB connection failure.
|
||||
@@ -618,7 +619,7 @@ func TestTruncate_utf8Safe_regression2026(t *testing.T) {
|
||||
filler += "a"
|
||||
}
|
||||
input := filler + "…xxx" // 195 ASCII + 3-byte rune + 3 trailing
|
||||
out := truncate(input, 200)
|
||||
out := textutil.TruncateBytes(input, 200)
|
||||
|
||||
if !utf8.ValidString(out) {
|
||||
t.Fatalf("truncate produced invalid UTF-8: %x", []byte(out))
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package textutil provides string-handling helpers that respect UTF-8
|
||||
// rune boundaries.
|
||||
//
|
||||
// Why this package exists
|
||||
// -----------------------
|
||||
// `s[:max]` truncates by BYTES; for any string with a multi-byte
|
||||
// codepoint at byte `max` (CJK, emoji, accented Latin), the slice
|
||||
// produces invalid UTF-8. Postgres `text` and `jsonb` columns reject
|
||||
// invalid UTF-8 with `invalid byte sequence for encoding "UTF8"`,
|
||||
// which silently fails the INSERT and holds the surrounding tx open
|
||||
// — a class of audit-gap that has bitten this codebase three times
|
||||
// (scheduler.go #2026, agent_message_writer.go #2959,
|
||||
// delegation_ledger.go #2962). Six per-package helpers had
|
||||
// independently re-implemented this logic with varying correctness;
|
||||
// this package is the single source of truth.
|
||||
//
|
||||
// Use sites
|
||||
// ---------
|
||||
// - DB writes whose column is bytes-bounded (jsonb preview field,
|
||||
// varchar(N)): TruncateBytes / TruncateBytesNoMarker.
|
||||
// - UI summaries whose cap is in display chars, not bytes:
|
||||
// TruncateRunes.
|
||||
//
|
||||
// All functions guarantee `utf8.ValidString(out) == true` for any
|
||||
// `s` where `utf8.ValidString(s) == true`. Inputs that are already
|
||||
// invalid UTF-8 should be sanitized at the trust boundary (e.g. via
|
||||
// `strings.ToValidUTF8`); this package does not silently fix
|
||||
// upstream invalid input.
|
||||
package textutil
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
// ellipsis is the truncation marker. U+2026 HORIZONTAL ELLIPSIS —
|
||||
// 3 bytes in UTF-8, 1 rune, 1 display column. Standardized across
|
||||
// the codebase to avoid the "..." (3 ASCII chars) vs "…" (1 char)
|
||||
// inconsistency the per-package helpers had drifted into.
|
||||
const ellipsis = "…"
|
||||
|
||||
// TruncateBytes returns s if `len(s) <= maxBytes`, otherwise returns
|
||||
// the longest rune-aligned prefix of s that fits in `maxBytes - 3`
|
||||
// bytes followed by the ellipsis marker. The returned string is
|
||||
// always at most `maxBytes` bytes long.
|
||||
//
|
||||
// Example: TruncateBytes("你好世界你好", 10) returns "你好世…" (9 bytes)
|
||||
// — three "你好" runes (each 3 bytes = 9 bytes) plus "…" (3 bytes)
|
||||
// would be 12 bytes, so we walk back to "你好" (6 bytes) + "…" (3) = 9.
|
||||
//
|
||||
// Edge cases:
|
||||
// - maxBytes <= 0: returns "" (no room even for input or marker)
|
||||
// - maxBytes < len(ellipsis): returns "" (can't add marker without
|
||||
// exceeding cap, and we won't return a marker-less truncation
|
||||
// here — caller wanted a marker; use TruncateBytesNoMarker if
|
||||
// they don't)
|
||||
// - s contains invalid UTF-8: continuation bytes are walked over
|
||||
// same as valid runes; the result preserves the (invalid) input
|
||||
// bytes up to the truncation point. Caller is responsible for
|
||||
// pre-sanitizing if Postgres validity is required.
|
||||
func TruncateBytes(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
if maxBytes < len(ellipsis) {
|
||||
return ""
|
||||
}
|
||||
// Reserve room for the marker, then walk back to the nearest
|
||||
// rune boundary at or below the cut point.
|
||||
cut := maxBytes - len(ellipsis)
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + ellipsis
|
||||
}
|
||||
|
||||
// TruncateBytesNoMarker returns s if `len(s) <= maxBytes`, otherwise
|
||||
// returns the longest rune-aligned prefix of s that fits in
|
||||
// `maxBytes` bytes. No marker is appended — useful when the caller's
|
||||
// storage already conveys "preview" / "snippet" semantics and an
|
||||
// extra ellipsis would push the result over a hard column cap.
|
||||
//
|
||||
// Example: TruncateBytesNoMarker("hello world", 5) returns "hello".
|
||||
//
|
||||
// Edge case: maxBytes <= 0 returns "".
|
||||
func TruncateBytesNoMarker(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
cut := maxBytes
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
|
||||
// TruncateRunes returns s if it has at most maxRunes runes, otherwise
|
||||
// returns the first maxRunes runes followed by the ellipsis marker.
|
||||
// Use this when the cap is in user-visible characters (UI summary,
|
||||
// activity feed line) rather than bytes (DB column).
|
||||
//
|
||||
// Example: TruncateRunes("你好世界你好", 3) returns "你好世…" — three
|
||||
// runes plus the marker, regardless of the resulting byte count.
|
||||
//
|
||||
// Edge case: maxRunes <= 0 returns "" (caller asked for no content).
|
||||
func TruncateRunes(s string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Fast path: if every byte is a single-byte rune, the byte-length
|
||||
// upper-bounds the rune count. This avoids a runes alloc for the
|
||||
// common ASCII case where the input fits.
|
||||
if len(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
// Walk by rune boundaries; stop at the (maxRunes+1)-th rune so we
|
||||
// know the cut point and that truncation is needed.
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == maxRunes {
|
||||
return s[:i] + ellipsis
|
||||
}
|
||||
count++
|
||||
}
|
||||
// Reachable when the byte count exceeded maxRunes but the actual
|
||||
// rune count didn't (e.g. all single-byte runes that just happen
|
||||
// to be more than maxRunes). The fast path catches len(s) <=
|
||||
// maxRunes; this catches maxRunes < runeCount(s) <= len(s).
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package textutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestTruncateBytes_RuneBoundary pins the byte-cap, marker-bearing
|
||||
// truncation path. Every case asserts both:
|
||||
// 1. the exact expected output (so a refactor that flips ellipsis or
|
||||
// drops a rune is caught), and
|
||||
// 2. utf8.ValidString on the output (the invariant that the bug class
|
||||
// in #2026/#2959/#2962 violated by slicing mid-codepoint).
|
||||
//
|
||||
// Per memory feedback_assert_exact_not_substring.md, asserts are exact
|
||||
// equality, not substring matches.
|
||||
func TestTruncateBytes_RuneBoundary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
maxBytes int
|
||||
want string
|
||||
}{
|
||||
// Under-cap: returns input verbatim.
|
||||
{"empty", "", 10, ""},
|
||||
{"under-cap ASCII", "hi", 10, "hi"},
|
||||
{"exactly-at-cap ASCII", "hello", 5, "hello"},
|
||||
{"under-cap CJK", "你好", 10, "你好"}, // 6 bytes
|
||||
{"exactly-at-cap CJK", "你好", 6, "你好"},
|
||||
|
||||
// Over-cap ASCII: trims to (maxBytes - 3) bytes + "…".
|
||||
{"over-cap ASCII", "abcdefghij", 6, "abc…"},
|
||||
|
||||
// Over-cap CJK where cut would land mid-codepoint. The
|
||||
// pre-fix bug shape: 7 - 3 = 4, but byte 4 is mid-"好"
|
||||
// (好 is bytes 3..5 of "你好世界"). Walking back to byte 3
|
||||
// (start of 好 — wait, that IS the start). Actually 你=0..2,
|
||||
// 好=3..5, 世=6..8, 界=9..11. Cut=4, walk back to 3 (start
|
||||
// of 好), then s[:3]="你", + "…" = "你…" (3+3=6 bytes ≤ 7).
|
||||
{"over-cap CJK lands mid-codepoint", "你好世界", 7, "你…"},
|
||||
|
||||
// Over-cap CJK where cut lands exactly on rune boundary.
|
||||
// 9 - 3 = 6, byte 6 is start of 世. Walk-back is no-op.
|
||||
// s[:6]="你好" + "…" = "你好…" (9 bytes).
|
||||
{"over-cap CJK rune-aligned", "你好世界", 9, "你好…"},
|
||||
|
||||
// Emoji: 😀 is 4 bytes (U+1F600). 7 - 3 = 4, byte 4 is start
|
||||
// of second 😀 — walk-back no-op. s[:4]="😀" + "…" = "😀…".
|
||||
{"over-cap emoji", "😀😀😀", 7, "😀…"},
|
||||
|
||||
// Mixed ASCII + CJK. "ab你好世界": a(1) b(1) 你(3) 好(3) 世(3) 界(3) = 14 bytes.
|
||||
// maxBytes=8, 8-3=5. byte 5 is mid-好. Walk back to start of 好 = byte 5? Let me
|
||||
// recompute: a=0, b=1, 你=2..4, 好=5..7, 世=8..10. Byte 5 IS start of 好.
|
||||
// Walk-back keeps cut at 5. s[:5] = "ab你" + "…" = "ab你…" (8 bytes).
|
||||
{"mixed prefix ASCII over-cap CJK", "ab你好世界", 8, "ab你…"},
|
||||
|
||||
// Pathological: maxBytes too small to even fit the marker.
|
||||
{"cap below ellipsis len", "hello", 2, ""},
|
||||
{"cap zero", "hello", 0, ""},
|
||||
{"cap negative", "hello", -1, ""},
|
||||
|
||||
// Cap exactly == ellipsis len: no room for content, but
|
||||
// the marker fits. This returns "" (cut = 0, s[:0] = "").
|
||||
{"cap equals ellipsis len", "hello", 3, "…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := TruncateBytes(c.in, c.maxBytes)
|
||||
if got != c.want {
|
||||
t.Errorf("TruncateBytes(%q, %d) = %q, want %q", c.in, c.maxBytes, got, c.want)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("TruncateBytes(%q, %d) returned invalid UTF-8: %q", c.in, c.maxBytes, got)
|
||||
}
|
||||
// Output never exceeds the byte cap (when one is set).
|
||||
if c.maxBytes > 0 && len(got) > c.maxBytes {
|
||||
t.Errorf("TruncateBytes(%q, %d) overflowed cap: len(out)=%d > %d",
|
||||
c.in, c.maxBytes, len(got), c.maxBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateBytesNoMarker pins the marker-less variant. Same
|
||||
// boundary handling as TruncateBytes but no ellipsis cost — the cut
|
||||
// happens at maxBytes itself, walking back only if that lands
|
||||
// mid-codepoint.
|
||||
func TestTruncateBytesNoMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
maxBytes int
|
||||
want string
|
||||
}{
|
||||
{"empty", "", 10, ""},
|
||||
{"under-cap ASCII", "hi", 10, "hi"},
|
||||
{"exactly-at-cap ASCII", "hello", 5, "hello"},
|
||||
{"over-cap ASCII", "abcdefghij", 5, "abcde"},
|
||||
|
||||
// Over-cap CJK rune-aligned: "你好世界", maxBytes=6, byte 6 is start of 世.
|
||||
// s[:6]="你好" — perfect cut.
|
||||
{"over-cap CJK rune-aligned", "你好世界", 6, "你好"},
|
||||
|
||||
// Over-cap CJK mid-codepoint: maxBytes=4, byte 4 is mid-好.
|
||||
// Walk back to byte 3 (start of 好), s[:3]="你".
|
||||
{"over-cap CJK mid-codepoint", "你好世界", 4, "你"},
|
||||
|
||||
// Emoji: maxBytes=5, "😀😀" is bytes 0..3 then 4..7. byte 5 is mid-second-😀.
|
||||
// Walk back to byte 4 (start of second 😀), s[:4]="😀".
|
||||
{"over-cap emoji", "😀😀", 5, "😀"},
|
||||
|
||||
// Edge: cap zero or negative → "".
|
||||
{"cap zero", "hello", 0, ""},
|
||||
{"cap negative", "hello", -1, ""},
|
||||
|
||||
// Cap = 1 and first rune is multi-byte: walk-back to 0, return "".
|
||||
{"cap one with leading CJK", "你hello", 1, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := TruncateBytesNoMarker(c.in, c.maxBytes)
|
||||
if got != c.want {
|
||||
t.Errorf("TruncateBytesNoMarker(%q, %d) = %q, want %q", c.in, c.maxBytes, got, c.want)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("TruncateBytesNoMarker(%q, %d) returned invalid UTF-8: %q", c.in, c.maxBytes, got)
|
||||
}
|
||||
if c.maxBytes > 0 && len(got) > c.maxBytes {
|
||||
t.Errorf("TruncateBytesNoMarker(%q, %d) overflowed cap: len(out)=%d > %d",
|
||||
c.in, c.maxBytes, len(got), c.maxBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateRunes pins the rune-cap variant. The key contract is
|
||||
// that maxRunes counts user-visible characters (Go runes, which line
|
||||
// up with Unicode codepoints), not bytes — so "你好世界" with
|
||||
// maxRunes=2 returns "你好…", regardless of the resulting byte count.
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
maxRunes int
|
||||
want string
|
||||
}{
|
||||
{"empty", "", 5, ""},
|
||||
{"under-cap ASCII", "hi", 5, "hi"},
|
||||
{"exactly-at-cap ASCII", "hello", 5, "hello"},
|
||||
{"over-cap ASCII", "abcdefghij", 5, "abcde…"},
|
||||
|
||||
{"under-cap CJK", "你好", 5, "你好"},
|
||||
{"exactly-at-cap CJK", "你好", 2, "你好"},
|
||||
|
||||
// Over-cap CJK: maxRunes=3, expect first 3 runes + marker.
|
||||
{"over-cap CJK", "你好世界你好", 3, "你好世…"},
|
||||
|
||||
// Emoji is one rune per glyph in Go (no ZWJ here).
|
||||
{"over-cap emoji", "😀😀😀😀😀", 2, "😀😀…"},
|
||||
|
||||
// Mixed: maxRunes=3 of "ab你好世界" → "ab你…".
|
||||
{"mixed prefix", "ab你好世界", 3, "ab你…"},
|
||||
|
||||
// Edge: maxRunes 0 / negative → "".
|
||||
{"cap zero", "hello", 0, ""},
|
||||
{"cap negative", "hello", -1, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := TruncateRunes(c.in, c.maxRunes)
|
||||
if got != c.want {
|
||||
t.Errorf("TruncateRunes(%q, %d) = %q, want %q", c.in, c.maxRunes, got, c.want)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("TruncateRunes(%q, %d) returned invalid UTF-8: %q", c.in, c.maxRunes, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncate_FuzzInvariants stays as a property-style sanity check:
|
||||
// for any rune-valid input and any cap, the output is rune-valid and
|
||||
// (for byte-cap variants) within the cap. This catches off-by-one
|
||||
// regressions in cuts that slip past the table-test cases above.
|
||||
func TestTruncate_FuzzInvariants(t *testing.T) {
|
||||
inputs := []string{
|
||||
"",
|
||||
"a",
|
||||
"hello world",
|
||||
"你好世界",
|
||||
"😀😀😀",
|
||||
"ab你c好d世e界",
|
||||
"日本語の文字列",
|
||||
"🇺🇸🇯🇵", // flags: each is 2 codepoints (regional indicators)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
for cap := -1; cap <= len(in)+5; cap++ {
|
||||
t.Run("", func(t *testing.T) {
|
||||
gotB := TruncateBytes(in, cap)
|
||||
if !utf8.ValidString(gotB) {
|
||||
t.Errorf("TruncateBytes(%q, %d) invalid UTF-8: %q", in, cap, gotB)
|
||||
}
|
||||
if cap > 0 && len(gotB) > cap {
|
||||
t.Errorf("TruncateBytes(%q, %d) overflowed: %q (%d bytes)", in, cap, gotB, len(gotB))
|
||||
}
|
||||
|
||||
gotN := TruncateBytesNoMarker(in, cap)
|
||||
if !utf8.ValidString(gotN) {
|
||||
t.Errorf("TruncateBytesNoMarker(%q, %d) invalid UTF-8: %q", in, cap, gotN)
|
||||
}
|
||||
if cap > 0 && len(gotN) > cap {
|
||||
t.Errorf("TruncateBytesNoMarker(%q, %d) overflowed: %q (%d bytes)", in, cap, gotN, len(gotN))
|
||||
}
|
||||
|
||||
gotR := TruncateRunes(in, cap)
|
||||
if !utf8.ValidString(gotR) {
|
||||
t.Errorf("TruncateRunes(%q, %d) invalid UTF-8: %q", in, cap, gotR)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user