Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f949ef4ad | |||
| 43a77ccfbc | |||
| c7eeec1607 | |||
| 2e8603f940 | |||
| 2045388293 | |||
| a118c63cd9 | |||
| 02a37a360c | |||
| 5a05302cd6 | |||
| 59b4f44224 | |||
| ee55473812 | |||
| 29b4bffb13 | |||
| 5dc1e462de | |||
| ec96a8f600 | |||
| 3198a3ee5d | |||
| 85b93feacc | |||
| da2fefa398 | |||
| 8f4c00ba05 | |||
| 106baadf2b | |||
| 6b80dca1f4 | |||
| 2f5b145c58 | |||
| 420ac2f00d | |||
| 8fced20267 | |||
| 7b3e3fc189 | |||
| 51a0fd2688 | |||
| d4bf57392e | |||
| 369578e96a | |||
| 5888238147 | |||
| c704e96117 | |||
| a86e3c7048 | |||
| 69f46d56c7 | |||
| c11a5e37ce | |||
| 1df0e378b6 | |||
| 9ce484886d | |||
| f33c5bd65e | |||
| 25866ec200 | |||
| c1d23380b6 | |||
| 679ed9a697 | |||
| 785112955f | |||
| af90c80e52 | |||
| 3c1a46b067 | |||
| 3868143c01 | |||
| b72ec7dcfc | |||
| f3e979b78c | |||
| 4ed6e36ef1 | |||
| 2d7232cf41 | |||
| b75fe86470 | |||
| e51f7004b3 | |||
| 2686b09449 | |||
| 25982862f7 | |||
| 8868cbe1a4 | |||
| 0cf2fa6297 | |||
| 4ce3bfa3aa | |||
| c9f53a2a28 | |||
| 99df6504de | |||
| 7b84d09de2 | |||
| eb67db9d7f | |||
| 39f2dd99aa | |||
| c38df4df9c | |||
| 51f5aa82ee | |||
| 77e511f905 | |||
| 1a4d012383 | |||
| 15c058071a | |||
| 146009af51 | |||
| 3a902747c3 | |||
| a50ed4169a | |||
| c7ffa43166 | |||
| 9b445366f6 | |||
| 3fadf89e43 | |||
| 7a768060e3 | |||
| f06afb18e3 | |||
| 7a614f2e3b | |||
| 45fb96e475 | |||
| 8ec2f4f33d | |||
| 6baeb1f7e2 | |||
| c6023e45d1 | |||
| 033c1b9bd4 | |||
| b1f740013d | |||
| 19fce4d400 | |||
| 096faa2562 | |||
| 1c3b4ff321 | |||
| 3ddc8a0300 | |||
| 420c42a202 | |||
| cee43a6dd8 | |||
| 499e204a82 | |||
| a3a358f968 | |||
| ed01130536 | |||
| 3359580502 | |||
| c0bbcb7756 | |||
| 20241de570 | |||
| 5738f53ee8 | |||
| 0b47f9516d | |||
| 2a476c3bbb | |||
| 7888f96f45 | |||
| 4e92e46182 | |||
| f417c1a870 | |||
| 8628d5cd2d | |||
| 4262c0a3db | |||
| 1dd6697031 | |||
| 5e6c490b19 | |||
| cdb0b0401a | |||
| 3297d16093 | |||
| e0e5dd911f | |||
| a50f51eb8f | |||
| e11f1f3c06 | |||
| 126edf74c1 |
@@ -0,0 +1 @@
|
||||
refire:1778784369
|
||||
@@ -203,12 +203,17 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
|
||||
|
||||
def ci_job_names(ci_doc: dict) -> set[str]:
|
||||
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
|
||||
whose `if:` gates on `github.event_name` (those are event-scoped
|
||||
and can legitimately be `skipped` for a given trigger; if we
|
||||
required them under the sentinel `needs:`, every PR-only job
|
||||
whose `if:` gates on `github.event_name` or `github.ref` (those are
|
||||
event-scoped and can legitimately be `skipped` for a given trigger;
|
||||
if we required them under the sentinel `needs:`, every PR-only job
|
||||
would be `skipped` on push and the sentinel would interpret
|
||||
`skipped != success` as failure). RFC §4 spec.
|
||||
|
||||
`github.ref` is the companion gate for jobs that run only on direct
|
||||
pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`).
|
||||
These never execute in a PR context, so flagging them as missing
|
||||
from `all-required.needs:` is a false positive (mc#958 / mc#959).
|
||||
|
||||
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
|
||||
(typos in needs) — see `ci_jobs_all` for that."""
|
||||
jobs = ci_doc.get("jobs")
|
||||
@@ -221,7 +226,9 @@ def ci_job_names(ci_doc: dict) -> set[str]:
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
gate = v.get("if")
|
||||
if isinstance(gate, str) and "github.event_name" in gate:
|
||||
if isinstance(gate, str) and (
|
||||
"github.event_name" in gate or "github.ref" in gate
|
||||
):
|
||||
continue
|
||||
names.add(k)
|
||||
return names
|
||||
|
||||
@@ -284,7 +284,8 @@ def list_queued_issues() -> list[dict]:
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
"labels": QUEUE_LABEL,
|
||||
# NOTE: Gitea 1.22.6 uses `label` (singular), not `labels` (plural).
|
||||
"label": QUEUE_LABEL,
|
||||
"limit": "50",
|
||||
},
|
||||
)
|
||||
@@ -417,7 +418,21 @@ def main() -> int:
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
_require_runtime_env()
|
||||
return process_once(dry_run=args.dry_run)
|
||||
try:
|
||||
return process_once(dry_run=args.dry_run)
|
||||
except ApiError as exc:
|
||||
# API errors (401/403/404/500) are transient for a queue tick —
|
||||
# log and exit 0 so the workflow is not marked failed and the next
|
||||
# tick can retry. Returning non-zero would permanently fail the
|
||||
# workflow run, blocking future ticks.
|
||||
sys.stderr.write(f"::error::queue API error: {exc}\n")
|
||||
return 0
|
||||
except urllib.error.URLError as exc:
|
||||
sys.stderr.write(f"::error::queue network error: {exc}\n")
|
||||
return 0
|
||||
except TimeoutError as exc:
|
||||
sys.stderr.write(f"::error::queue timeout: {exc}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -118,19 +118,17 @@ _DIRECTIVE_RE = re.compile(
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> tuple[list[tuple[str, str, str]], list]:
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns (directives, na_directives) where:
|
||||
directives is a list of (kind, canonical_slug, note) tuples
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
na_directives is reserved for future N/A handling (always [] for now)
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out, []
|
||||
return out
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
@@ -161,7 +159,7 @@ def parse_directives(
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out, []
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -251,8 +249,7 @@ def compute_ack_state(
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
directives, _na = parse_directives(body, numeric_aliases)
|
||||
for kind, slug, _note in directives:
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
|
||||
+112
-101
@@ -348,16 +348,15 @@ jobs:
|
||||
# Shellcheck (E2E scripts) — required check, always runs.
|
||||
shellcheck:
|
||||
name: Shellcheck (E2E scripts)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- if: needs.changes.outputs.scripts != 'true'
|
||||
- if: false
|
||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
- if: always()
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
- if: always()
|
||||
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
|
||||
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
|
||||
# infra/scripts/ is included because setup.sh + nuke.sh gate the
|
||||
@@ -368,16 +367,16 @@ jobs:
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
- if: always()
|
||||
name: Lint cleanup-trap hygiene (RFC #2873)
|
||||
run: bash tests/e2e/lint_cleanup_traps.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
- if: always()
|
||||
name: Run E2E bash unit tests (no live infra)
|
||||
run: |
|
||||
bash tests/e2e/test_model_slug.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
- if: always()
|
||||
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
|
||||
# Covers scripts/promote-tenant-image.sh — the codified
|
||||
# :staging-latest → :latest ECR promote + tenant fleet redeploy
|
||||
@@ -387,7 +386,7 @@ jobs:
|
||||
run: |
|
||||
bash scripts/test-promote-tenant-image.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
- if: always()
|
||||
name: Shellcheck promote-tenant-image script
|
||||
# scripts/ is excluded from the bulk shellcheck pass above (legacy
|
||||
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
|
||||
@@ -398,23 +397,18 @@ jobs:
|
||||
scripts/promote-tenant-image.sh \
|
||||
scripts/test-promote-tenant-image.sh
|
||||
|
||||
# mc#959 root-fix (sre)
|
||||
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
|
||||
# ci_job_names() detects this as github.ref-gated and skips it from F1.
|
||||
# The step-level exit 0 handles the "not main push" case; the job-level
|
||||
# `if:` makes the gating explicit so the drift script sees it.
|
||||
# continue-on-error removed (was mc#774 mask): step exits 0 when not applicable.
|
||||
if: ${{ github.ref == 'refs/heads/staging' }}
|
||||
needs: [changes, canvas-build]
|
||||
# This job must run on PRs because all-required needs it. The step exits
|
||||
# 0 when it is not a main push, giving branch protection a green no-op
|
||||
# instead of a skipped/missing required dependency.
|
||||
needs: canvas-build
|
||||
steps:
|
||||
- name: Write deploy reminder to step summary
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
CANVAS_CHANGED: ${{ needs.changes.outputs.canvas }}
|
||||
CANVAS_CHANGED: "true"
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref }}
|
||||
# github.server_url resolves via the workflow-level env override
|
||||
@@ -459,7 +453,6 @@ jobs:
|
||||
# Python Lint & Test — required check, always runs.
|
||||
python-lint:
|
||||
name: Python Lint & Test
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
@@ -469,25 +462,25 @@ jobs:
|
||||
run:
|
||||
working-directory: workspace
|
||||
steps:
|
||||
- if: needs.changes.outputs.python != 'true'
|
||||
- if: false
|
||||
working-directory: .
|
||||
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
- if: always()
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
- if: always()
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
- if: always()
|
||||
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
|
||||
# Coverage flags + fail-under floor moved into workspace/pytest.ini
|
||||
# (issue #1817) so local `pytest` and CI use identical config.
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
- if: always()
|
||||
run: python -m pytest --tb=short
|
||||
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
- if: always()
|
||||
name: Per-file critical-path coverage (MCP / inbox / auth)
|
||||
# MCP-critical Python files have a per-file floor on top of the
|
||||
# 86% total floor in pytest.ini. See issue #2790 for full rationale.
|
||||
@@ -552,86 +545,104 @@ jobs:
|
||||
# red silently merged through. See internal#286 for the three concrete
|
||||
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
|
||||
#
|
||||
# Three properties of this job each close a failure mode:
|
||||
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
|
||||
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
|
||||
# jobs settle, leaving branch protection with a permanent pending
|
||||
# `CI / all-required` context. Instead, this independent sentinel polls the
|
||||
# required commit-status contexts for this SHA and fails if any fail, skip,
|
||||
# or never emit.
|
||||
#
|
||||
# 1. `if: always()` — runs even when an upstream fails. Without it the
|
||||
# sentinel is `skipped` and protection treats that as missing → merge
|
||||
# ungated.
|
||||
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
|
||||
# It is an informational main-push reminder, not a PR quality gate. Keeping
|
||||
# it in this dependency list lets a skipped reminder skip the required
|
||||
# sentinel before the `always()` guard can emit a branch-protection status.
|
||||
#
|
||||
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
|
||||
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
|
||||
# entry that couldn't run) must NOT silently pass through.
|
||||
# `skipped`-as-green is exactly the failure mode this gate closes.
|
||||
#
|
||||
# 3. `needs:` is the canonical list of "what counts as required."
|
||||
# status_check_contexts will reference only `ci/all-required` (Step 5
|
||||
# follow-up — branch-protection PATCH is Owners-tier per
|
||||
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
|
||||
# added simply by listing it in `needs:` here.
|
||||
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
|
||||
# hourly if this list diverges from status_check_contexts or from
|
||||
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
|
||||
#
|
||||
# canvas-deploy-reminder is intentionally excluded from all-required.needs:
|
||||
# it needs canvas-build, which is skipped on CI-only PRs (canvas=false).
|
||||
# Including it in all-required.needs causes all-required to hang on
|
||||
# every CI-only PR. Keep it runnable on PRs via its own
|
||||
# `needs: [changes, canvas-build]` — the sentinel only aggregates the result.
|
||||
#
|
||||
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
|
||||
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
|
||||
# (Gitea suppresses status reporting for CoE jobs). This sentinel
|
||||
# runs with continue-on-error: false so it always reports its
|
||||
# result to the API — without this, the required-status entry
|
||||
# (CI / all-required (pull_request)) is never created, which
|
||||
# blocks PR merges. When Phase 3 ends, flip underlying jobs to
|
||||
# continue-on-error: false; this sentinel can then be flipped to
|
||||
# continue-on-error: true if a Phase-4 regression requires it.
|
||||
continue-on-error: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
needs:
|
||||
- changes
|
||||
- platform-build
|
||||
- canvas-build
|
||||
- shellcheck
|
||||
- python-lint
|
||||
- canvas-deploy-reminder
|
||||
if: ${{ always() }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Assert every required dependency succeeded
|
||||
- name: Wait for required CI contexts
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API_ROOT: ${{ github.server_url }}/api/v1
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
|
||||
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
|
||||
# Null results are skipped: they come from Phase 3 (continue-on-error: true
|
||||
# suppresses status) or from jobs still in-flight. The sentinel succeeds
|
||||
# rather than blocking PRs on Phase 3 noise.
|
||||
results='${{ toJSON(needs) }}'
|
||||
echo "$results"
|
||||
echo "$results" | python3 -c '
|
||||
import json, sys
|
||||
ns = json.load(sys.stdin)
|
||||
# Phase 3 masked: jobs with continue-on-error: true may report "failure"
|
||||
# Remove when mc#774 handler test failures are resolved.
|
||||
PHASE3_MASKED = {"platform-build"}
|
||||
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
|
||||
bad = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
|
||||
if bad:
|
||||
print(f"FAIL: jobs not green:", file=sys.stderr)
|
||||
for k, r in bad:
|
||||
print(f" - {k}: {r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
pending = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") is None]
|
||||
cancelled = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") == "cancelled"]
|
||||
if pending:
|
||||
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
|
||||
", ".join(k for k, _ in pending), file=sys.stderr)
|
||||
if cancelled:
|
||||
print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
|
||||
", ".join(k for k, _ in cancelled), file=sys.stderr)
|
||||
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
|
||||
'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
api_root = os.environ["API_ROOT"].rstrip("/")
|
||||
repo = os.environ["REPOSITORY"]
|
||||
sha = os.environ["COMMIT_SHA"]
|
||||
event = os.environ["EVENT_NAME"]
|
||||
required = [
|
||||
f"CI / Detect changes ({event})",
|
||||
f"CI / Platform (Go) ({event})",
|
||||
f"CI / Canvas (Next.js) ({event})",
|
||||
f"CI / Shellcheck (E2E scripts) ({event})",
|
||||
f"CI / Python Lint & Test ({event})",
|
||||
]
|
||||
terminal_bad = {"failure", "error"}
|
||||
deadline = time.time() + 40 * 60
|
||||
last_summary = None
|
||||
|
||||
def fetch_statuses():
|
||||
statuses = []
|
||||
for page in range(1, 6):
|
||||
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
chunk = json.load(resp)
|
||||
if not chunk:
|
||||
break
|
||||
statuses.extend(chunk)
|
||||
latest = {}
|
||||
for item in statuses:
|
||||
ctx = item.get("context")
|
||||
if not ctx:
|
||||
continue
|
||||
prev = latest.get(ctx)
|
||||
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
|
||||
latest[ctx] = item
|
||||
return latest
|
||||
|
||||
while True:
|
||||
try:
|
||||
latest = fetch_statuses()
|
||||
except (TimeoutError, OSError, urllib.error.URLError) as exc:
|
||||
if time.time() >= deadline:
|
||||
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
|
||||
time.sleep(15)
|
||||
continue
|
||||
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
|
||||
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
|
||||
if summary != last_summary:
|
||||
print(summary, flush=True)
|
||||
last_summary = summary
|
||||
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
|
||||
if bad:
|
||||
print("FAIL: required CI context failed:", file=sys.stderr)
|
||||
for ctx, state in bad.items():
|
||||
desc = (latest.get(ctx) or {}).get("description") or ""
|
||||
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if all(state == "success" for state in states.values()):
|
||||
print(f"OK: all {len(required)} required CI contexts succeeded")
|
||||
sys.exit(0)
|
||||
if time.time() >= deadline:
|
||||
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
|
||||
for ctx, state in states.items():
|
||||
print(f" - {ctx}: {state}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
time.sleep(15)
|
||||
PY
|
||||
|
||||
@@ -69,6 +69,13 @@ name: E2E API Smoke Test
|
||||
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
|
||||
# they DO come up. Timeouts are not the bottleneck; not bumped.
|
||||
#
|
||||
# Item #1046 (fixed 2026-05-14): Stale platform-server from cancelled runs
|
||||
# lingers on :8080 after "Stop platform" step is skipped (workflow cancelled
|
||||
# before reaching line 335). Added a pre-start "Kill stale platform-server"
|
||||
# step (line 286) that scans /proc for zombie platform-server processes
|
||||
# and kills them before the port probe or bind. Makes the ephemeral port
|
||||
# probe + start sequence deterministic.
|
||||
#
|
||||
# Item explicitly NOT fixed here: failing test `Status back online`
|
||||
# fails because the platform's langgraph workspace template image
|
||||
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
|
||||
@@ -283,6 +290,35 @@ jobs:
|
||||
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "Platform host port: ${PLATFORM_PORT}"
|
||||
- name: Kill stale platform-server before start (issue #1046)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
# Concurrent runs on the same host-network act_runner can leave a
|
||||
# zombie platform-server from a cancelled/timeout run. Cancelled
|
||||
# runs never reach the "Stop platform" step (line 335), so the
|
||||
# old process lingers. Kill it before the ephemeral port probe
|
||||
# or start so the port is definitively free.
|
||||
#
|
||||
# /proc scan — works on any Linux without pkill/lsof/ss.
|
||||
# comm field is truncated to 15 chars: "platform-serve" matches
|
||||
# "platform-server". Verify with cmdline to avoid false positives.
|
||||
killed=0
|
||||
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
|
||||
kpid="${pid%/comm}"
|
||||
kpid="${kpid##*/}"
|
||||
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
|
||||
if echo "$cmdline" | grep -q "platform-server"; then
|
||||
echo "Killing stale platform-server pid ${kpid}: ${cmdline}"
|
||||
kill "$kpid" 2>/dev/null || true
|
||||
killed=$((killed + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$killed" -gt 0 ]; then
|
||||
sleep 2
|
||||
echo "Killed $killed stale process(es); port(s) released."
|
||||
else
|
||||
echo "No stale platform-server found."
|
||||
fi
|
||||
- name: Start platform (background)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
working-directory: workspace-server
|
||||
@@ -346,3 +382,4 @@ jobs:
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
name: E2E Chat
|
||||
|
||||
# Comprehensive Playwright E2E for the unified chat stack (desktop
|
||||
# ChatTab + mobile MobileChat). Runs on every PR that touches canvas,
|
||||
# workspace-server, or this workflow file.
|
||||
#
|
||||
# Architecture:
|
||||
# 1. Ephemeral Postgres + Redis (docker, unique container names)
|
||||
# 2. workspace-server built from source, started with
|
||||
# MOLECULE_ENV=development (fail-open auth)
|
||||
# 3. canvas dev server (npm run dev) on :3000
|
||||
# 4. Playwright tests create workspaces via API, point them at an
|
||||
# in-process echo runtime, and exercise the full send/receive
|
||||
# round-trip through the browser.
|
||||
#
|
||||
# Parallel-safety: same pattern as e2e-api.yml — per-run container names
|
||||
# and ephemeral host ports so concurrent jobs on the host-network runner
|
||||
# don't collide.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
|
||||
concurrency:
|
||||
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
chat: ${{ steps.decide.outputs.chat }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: decide
|
||||
run: |
|
||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
|
||||
echo "chat=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
||||
fi
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
echo "chat=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD)
|
||||
if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
|
||||
echo "chat=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "chat=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs.
|
||||
e2e-chat:
|
||||
needs: detect-changes
|
||||
name: E2E Chat
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
steps:
|
||||
- name: No-op pass (paths filter excluded this commit)
|
||||
if: needs.detect-changes.outputs.chat != 'true'
|
||||
run: |
|
||||
echo "No canvas / workspace-server / workflow changes — E2E Chat gate satisfied without running tests."
|
||||
echo "::notice::E2E Chat no-op pass (paths filter excluded this commit)."
|
||||
|
||||
- if: needs.detect-changes.outputs.chat == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- if: needs.detect-changes.outputs.chat == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
|
||||
- if: needs.detect-changes.outputs.chat == 'true'
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d6f5 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: canvas/package-lock.json
|
||||
|
||||
- name: Start Postgres (docker)
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
||||
-p 0:5432 postgres:16 >/dev/null
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
||||
fi
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $PG_CONTAINER"
|
||||
exit 1
|
||||
fi
|
||||
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
|
||||
echo "Postgres ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Postgres did not become ready in 30s"
|
||||
exit 1
|
||||
|
||||
- name: Start Redis (docker)
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
||||
fi
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
|
||||
exit 1
|
||||
fi
|
||||
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
|
||||
echo "Redis ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Redis did not become ready in 15s"
|
||||
exit 1
|
||||
|
||||
- name: Build platform
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
working-directory: workspace-server
|
||||
run: go build -o platform-server ./cmd/server
|
||||
|
||||
- name: Pick platform port
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
PLATFORM_PORT=$(python3 - <<'PY'
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
PY
|
||||
)
|
||||
echo "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "Platform host port: ${PLATFORM_PORT}"
|
||||
|
||||
- name: Start platform (background)
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
export MOLECULE_ENV=development
|
||||
export DATABASE_URL="${DATABASE_URL}"
|
||||
export REDIS_URL="${REDIS_URL}"
|
||||
export PORT="${PLATFORM_PORT}"
|
||||
./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
|
||||
- name: Wait for /health
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then
|
||||
echo "Platform up after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Platform did not become healthy in 30s"
|
||||
cat workspace-server/platform.log || true
|
||||
exit 1
|
||||
|
||||
- name: Install canvas dependencies
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
working-directory: canvas
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
working-directory: canvas
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Start canvas dev server (background)
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
working-directory: canvas
|
||||
run: |
|
||||
export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
|
||||
export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws"
|
||||
npm run dev > canvas.log 2>&1 &
|
||||
echo $! > canvas.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
|
||||
echo "Canvas up after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Canvas did not start in 30s"
|
||||
cat canvas.log || true
|
||||
exit 1
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: needs.detect-changes.outputs.chat == 'true'
|
||||
working-directory: canvas
|
||||
run: |
|
||||
export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
|
||||
export E2E_DATABASE_URL="${DATABASE_URL}"
|
||||
npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts
|
||||
|
||||
- name: Dump platform log on failure
|
||||
if: failure() && needs.detect-changes.outputs.chat == 'true'
|
||||
run: cat workspace-server/platform.log || true
|
||||
|
||||
- name: Dump canvas log on failure
|
||||
if: failure() && needs.detect-changes.outputs.chat == 'true'
|
||||
run: cat canvas/canvas.log || true
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() && needs.detect-changes.outputs.chat == 'true'
|
||||
uses: actions/upload-artifact@v3.2.2
|
||||
with:
|
||||
name: playwright-report-chat
|
||||
path: canvas/playwright-report/
|
||||
|
||||
- name: Stop canvas
|
||||
if: always() && needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
if [ -f canvas/canvas.pid ]; then
|
||||
kill "$(cat canvas/canvas.pid)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Stop platform
|
||||
if: always() && needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
if [ -f workspace-server/platform.pid ]; then
|
||||
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Stop service containers
|
||||
if: always() && needs.detect-changes.outputs.chat == 'true'
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
@@ -0,0 +1,225 @@
|
||||
name: E2E Peer Visibility (literal MCP list_peers)
|
||||
|
||||
# WHY A DEDICATED WORKFLOW (not folded into e2e-staging-saas.yml)
|
||||
# --------------------------------------------------------------
|
||||
# This is the systemic fix for a real trust failure. Hermes and OpenClaw
|
||||
# were reported "fleet-verified / cascade-complete" because the *proxy*
|
||||
# signals were green (registry registration + heartbeat for Hermes; model
|
||||
# round-trip 200 for OpenClaw). A freshly-provisioned workspace asked on
|
||||
# canvas "can you see your peers" actually FAILS:
|
||||
# - Hermes: 401 on the molecule MCP `list_peers` call
|
||||
# - OpenClaw: native `sessions_list` fallback, sees no platform peers
|
||||
# Tasks #142/#159 were even marked "completed" under this proxy flaw.
|
||||
#
|
||||
# A dedicated workflow (vs extending e2e-staging-saas.yml) because:
|
||||
# - It must provision MULTIPLE distinct runtimes (hermes, openclaw,
|
||||
# claude-code) in ONE org and assert each sees the others. The
|
||||
# full-saas script is single-runtime-per-run (E2E_RUNTIME) and folding
|
||||
# a multi-runtime matrix into it would conflate concerns and bloat its
|
||||
# already-45-min run.
|
||||
# - It needs its own concurrency group so it doesn't fight full-saas /
|
||||
# canvas for the staging org-creation quota.
|
||||
# - It needs an independent, non-required status-context name so it can
|
||||
# be RED today (the in-flight Hermes-401 / OpenClaw-MCP-wiring fixes
|
||||
# have not landed) WITHOUT wedging unrelated merges — and flipped to
|
||||
# REQUIRED in one branch-protection edit once it goes green
|
||||
# (flip-to-required checklist: molecule-core#1296).
|
||||
#
|
||||
# THE ASSERTION IS NOT A PROXY. The driving script
|
||||
# tests/e2e/test_peer_visibility_mcp_staging.sh issues the byte-for-byte
|
||||
# JSON-RPC `tools/call name=list_peers` envelope to `POST
|
||||
# /workspaces/:id/mcp` using each workspace's OWN bearer token, through
|
||||
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
|
||||
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
|
||||
# read a registry row, /health, the heartbeat table, or
|
||||
# GET /registry/:id/peers.
|
||||
#
|
||||
# HONEST GATE — NO continue-on-error. Per feedback_fix_root_not_symptom a
|
||||
# fake-green mask would defeat the entire purpose. This workflow goes red
|
||||
# on today's broken behavior and green only when the root-cause fixes
|
||||
# actually land. It is intentionally NOT in branch_protections — see PR
|
||||
# body for the required-vs-not decision + flip tracking issue.
|
||||
#
|
||||
# Gitea 1.22.6 / act_runner notes honored:
|
||||
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
|
||||
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
|
||||
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
|
||||
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
|
||||
# - Workflow-level GITHUB_SERVER_URL pinned
|
||||
# (feedback_act_runner_github_server_url).
|
||||
# - pr-validate posts a status under the same check name so a
|
||||
# workflow-only PR is not silently statusless and the context is
|
||||
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
|
||||
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
|
||||
# and cannot run per-PR-update).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/mcp.go'
|
||||
- 'workspace-server/internal/handlers/mcp_tools.go'
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/platform_tools/registry.py'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/mcp.go'
|
||||
- 'workspace-server/internal/handlers/mcp_tools.go'
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/platform_tools/registry.py'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 07:30 UTC daily — catches AMI / template-hermes / template-openclaw
|
||||
# drift even on quiet days. Offset 30m from e2e-staging-saas (07:00)
|
||||
# so the two don't collide on the staging org-creation quota.
|
||||
- cron: '30 7 * * *'
|
||||
|
||||
concurrency:
|
||||
# Per-SHA (feedback_concurrency_group_per_sha). A single global group
|
||||
# would let a queued staging/main push behind a PR run get cancelled,
|
||||
# leaving any gate that reads "completed run at SHA" stuck.
|
||||
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# PR path: post a real status under the required-ready check name so a
|
||||
# workflow-only PR is never silently statusless. The actual EC2 E2E is
|
||||
# push/dispatch/cron only (30+ min). This is NOT a fake-green mask of
|
||||
# the real assertion — it validates the driving script's bash syntax
|
||||
# and inline-python so a broken test script fails at PR time.
|
||||
pr-validate:
|
||||
name: E2E Peer Visibility
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate driving script
|
||||
run: |
|
||||
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
|
||||
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
|
||||
echo "Real fresh-provision MCP list_peers E2E runs on push to"
|
||||
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
|
||||
|
||||
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
|
||||
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
|
||||
# peer set, then scoped teardown. push(main)/dispatch/cron only.
|
||||
peer-visibility:
|
||||
name: E2E Peer Visibility
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
# LLM provider key so each runtime can authenticate at boot.
|
||||
# Priority MiniMax → direct-Anthropic → OpenAI matches
|
||||
# test_staging_full_saas.sh's secrets-injection chain.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
PV_RUNTIMES: "hermes openclaw claude-code"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present"
|
||||
|
||||
- name: Verify an LLM key present
|
||||
run: |
|
||||
if [ -z "${E2E_MINIMAX_API_KEY:-}" ] && [ -z "${E2E_ANTHROPIC_API_KEY:-}" ] && [ -z "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
echo "::error::No LLM provider key set — workspaces fail at boot with 'No provider API key found'. Set MOLECULE_STAGING_MINIMAX_API_KEY (or ANTHROPIC / OPENAI)."
|
||||
exit 2
|
||||
fi
|
||||
echo "LLM key present"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a workspace bug. Failing loud per feedback_fix_root_not_symptom."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy"
|
||||
|
||||
- name: Run fresh-provision peer-visibility E2E (literal MCP list_peers)
|
||||
run: bash tests/e2e/test_peer_visibility_mcp_staging.sh
|
||||
|
||||
# Belt-and-braces scoped teardown: the script installs an EXIT/INT/
|
||||
# TERM trap, but if the runner itself is cancelled the trap may not
|
||||
# fire. This always() step deletes ONLY the e2e-pv-<run_id> org this
|
||||
# run created — never a cluster-wide sweep
|
||||
# (feedback_never_run_cluster_cleanup_tests_on_live_platform). The
|
||||
# admin DELETE is idempotent so double-invoking is safe;
|
||||
# sweep-stale-e2e-orgs is the final net (slug starts with 'e2e-').
|
||||
- name: Teardown safety net (runs on cancel/failure)
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
# ONLY sweep slugs from THIS run. e2e-pv-<YYYYMMDD>-<run_id>-...
|
||||
# Sweep today AND yesterday's UTC date so a midnight-crossing run
|
||||
# still matches its own slug (same bug class as the saas/canvas
|
||||
# safety nets).
|
||||
today = datetime.date.today()
|
||||
yest = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yest.strftime('%Y%m%d'))
|
||||
if run_id:
|
||||
prefixes = tuple(f'e2e-pv-{dt}-{run_id}-' for dt in dates)
|
||||
else:
|
||||
prefixes = tuple(f'e2e-pv-{dt}-' for dt in dates)
|
||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
||||
cands = [o['slug'] for o in orgs
|
||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
||||
and o.get('instance_status') not in ('purged',)]
|
||||
print('\n'.join(cands))
|
||||
" 2>/dev/null)
|
||||
for slug in $orgs; do
|
||||
echo "Safety-net teardown: $slug"
|
||||
set +e
|
||||
curl -sS -o /tmp/pv-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/tmp/pv-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/pv-cleanup.code 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::pv teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES. Body: $(head -c 300 /tmp/pv-cleanup.out 2>/dev/null)"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
@@ -83,25 +83,41 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch all open PRs and run gate-check on each
|
||||
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
|
||||
# gate_check.py uses timeout=15 on every urlopen call; this catches the
|
||||
# inline Python polling loop too (issue #603).
|
||||
# Fetch all open PRs and run gate-check on each. This scheduled
|
||||
# refresher is advisory; a transient Gitea list timeout must not turn
|
||||
# main red. PR-specific gate-check runs still use normal failure
|
||||
# semantics.
|
||||
pr_numbers=$(python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
socket.setdefaulttimeout(15)
|
||||
socket.setdefaulttimeout(30)
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
repo = os.environ["REPO"]
|
||||
req = urllib.request.Request(
|
||||
f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
prs = json.loads(r.read())
|
||||
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100"
|
||||
last_error = None
|
||||
for attempt in range(1, 4):
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
prs = json.loads(r.read())
|
||||
break
|
||||
except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
|
||||
last_error = exc
|
||||
print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr)
|
||||
if attempt < 3:
|
||||
time.sleep(2 * attempt)
|
||||
else:
|
||||
print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr)
|
||||
raise SystemExit(0)
|
||||
for pr in prs:
|
||||
print(pr["number"])
|
||||
PY
|
||||
|
||||
@@ -86,7 +86,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# A full-history checkout can exceed the runner's quiet/startup
|
||||
# window before the path filter emits logs. Fetch the common push
|
||||
# case cheaply; the script below fetches the exact BASE SHA if it is
|
||||
# not present in the shallow checkout.
|
||||
fetch-depth: 2
|
||||
- id: filter
|
||||
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
|
||||
run: |
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
lint:
|
||||
name: lint-continue-on-error-tracking
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
# Phase 3 (RFC #219 §1): surface masked defects without blocking
|
||||
# PRs. Pre-existing continue-on-error: true directives on main
|
||||
# all violate this lint at first — intentional. Flip to false
|
||||
|
||||
@@ -18,6 +18,10 @@ permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -70,7 +70,7 @@ name: sop-checklist
|
||||
# Cancel any in-progress runs for the same PR to prevent
|
||||
# stale runs from overwriting newer status contexts.
|
||||
concurrency:
|
||||
group: ${{ github.repository }}-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
|
||||
|
||||
@@ -61,6 +61,10 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed, edited]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tier-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
staging trigger
|
||||
staging trigger 2026-05-14T17:35:02Z
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
trigger
|
||||
@@ -1,173 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { startEchoRuntime } from "./fixtures/echo-runtime";
|
||||
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
|
||||
|
||||
|
||||
test.describe("Desktop ChatTab", () => {
|
||||
let cleanup: () => Promise<void> = async () => {};
|
||||
let workspaceId = "";
|
||||
let workspaceName = "";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const echo = await startEchoRuntime();
|
||||
const ws = await seedWorkspace(echo.baseURL);
|
||||
workspaceId = ws.id;
|
||||
workspaceName = ws.name;
|
||||
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
|
||||
|
||||
cleanup = async () => {
|
||||
stopHeartbeat();
|
||||
await echo.stop();
|
||||
};
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupWorkspace(workspaceId);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/");
|
||||
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
|
||||
// Dismiss onboarding guide if present.
|
||||
const skipGuide = page.getByText("Skip guide");
|
||||
if (await skipGuide.isVisible().catch(() => false)) {
|
||||
await skipGuide.click();
|
||||
}
|
||||
// Click the workspace node by its exact name label.
|
||||
await page.getByText(workspaceName, { exact: true }).first().click();
|
||||
// Wait for the side panel chat tab to be clickable, then click it.
|
||||
await page.locator('#tab-chat').click();
|
||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
|
||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("chat panel loads without error", async ({ page }) => {
|
||||
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
|
||||
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
|
||||
expect(hasEmptyState || hasHistory).toBeTruthy();
|
||||
});
|
||||
|
||||
test("send text message and receive echo response", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("What is the weather?");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("What is the weather?")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("history persists across reload", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("Persistence test");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
|
||||
await page.getByText(workspaceName, { exact: true }).first().click();
|
||||
await page.locator('#tab-chat').click();
|
||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
|
||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
||||
|
||||
await expect(page.getByText("Persistence test", { exact: true })).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("file attachment round-trip", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("Please read this file");
|
||||
|
||||
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
|
||||
await fileInput.setInputFiles({
|
||||
name: "test.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("secret content abc123"),
|
||||
});
|
||||
|
||||
await expect(page.getByText("test.txt")).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("activity log appears during send", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("Trigger activity");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
// Activity log container should appear during the send flow.
|
||||
await expect(page.locator("[data-testid='activity-log']").first()).toBeVisible({ timeout: 10_000 }).catch(() => {
|
||||
// Activity log may not be present in all layouts.
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop ChatTab — Markdown rendering", () => {
|
||||
let cleanup: () => Promise<void> = async () => {};
|
||||
let workspaceId = "";
|
||||
let workspaceName = "";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const echo = await startEchoRuntime();
|
||||
const ws = await seedWorkspace(echo.baseURL);
|
||||
workspaceId = ws.id;
|
||||
workspaceName = ws.name;
|
||||
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
|
||||
|
||||
cleanup = async () => {
|
||||
stopHeartbeat();
|
||||
await echo.stop();
|
||||
};
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupWorkspace(workspaceId);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/");
|
||||
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
|
||||
const skipGuide2 = page.getByText("Skip guide");
|
||||
if (await skipGuide2.isVisible().catch(() => false)) {
|
||||
await skipGuide2.click();
|
||||
}
|
||||
await page.getByText(workspaceName, { exact: true }).first().click();
|
||||
await page.locator('#tab-chat').click();
|
||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
|
||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("code block renders <pre>", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("```js\nconst x = 1;\n```");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("Echo: ```js")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const pre = page.locator("pre").first();
|
||||
await expect(pre).toBeVisible({ timeout: 5_000 });
|
||||
await expect(pre).toContainText("const x = 1;");
|
||||
});
|
||||
|
||||
test("table renders <table>", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("| A | B |\n|---|---|\n| 1 | 2 |");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("Echo: | A | B |")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const table = page.locator("table").first();
|
||||
await expect(table).toBeVisible({ timeout: 5_000 });
|
||||
await expect(table).toContainText("A");
|
||||
await expect(table).toContainText("1");
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { startEchoRuntime } from "./fixtures/echo-runtime";
|
||||
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
|
||||
|
||||
|
||||
test.describe("MobileChat", () => {
|
||||
let cleanup: () => Promise<void> = async () => {};
|
||||
let workspaceId = "";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const echo = await startEchoRuntime();
|
||||
const ws = await seedWorkspace(echo.baseURL);
|
||||
workspaceId = ws.id;
|
||||
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
|
||||
|
||||
cleanup = async () => {
|
||||
stopHeartbeat();
|
||||
await echo.stop();
|
||||
};
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupWorkspace(workspaceId);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
// Navigate directly to the mobile chat view.
|
||||
await page.goto(`/?m=chat&a=${workspaceId}`);
|
||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
|
||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
||||
// Dismiss onboarding guide if present.
|
||||
const skipGuide = page.getByText("Skip guide");
|
||||
if (await skipGuide.isVisible().catch(() => false)) {
|
||||
await skipGuide.click();
|
||||
}
|
||||
});
|
||||
|
||||
test("chat panel loads without error", async ({ page }) => {
|
||||
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
|
||||
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
|
||||
expect(hasEmptyState || hasHistory).toBeTruthy();
|
||||
});
|
||||
|
||||
test("send text message and receive echo response", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("Mobile test message");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("Mobile test message")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("Echo: Mobile test message")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("history persists across reload", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("Mobile persistence");
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
|
||||
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
|
||||
|
||||
await expect(page.getByText("Mobile persistence", { exact: true })).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("composer auto-grows with multi-line text", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
const initialHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
|
||||
|
||||
await textarea.fill("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const grownHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
|
||||
expect(grownHeight).toBeGreaterThan(initialHeight);
|
||||
});
|
||||
|
||||
test("file attachment in mobile chat", async ({ page }) => {
|
||||
const textarea = page.locator("textarea").first();
|
||||
await textarea.fill("Mobile file test");
|
||||
|
||||
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
|
||||
await fileInput.setInputFiles({
|
||||
name: "mobile.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("mobile secret"),
|
||||
});
|
||||
|
||||
await expect(page.getByText("mobile.txt")).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
||||
await expect(page.getByText("Echo: Mobile file test")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* E2E seed fixture for chat tests.
|
||||
*
|
||||
* Creates an external workspace via the workspace-server API, extracts the
|
||||
* auto-minted auth token, then overrides the DB row so it appears "online"
|
||||
* with an echo-runtime URL. External runtime is used because the health
|
||||
* sweep skips Docker checks for external workspaces; we keep the workspace
|
||||
* alive with periodic heartbeats.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
|
||||
|
||||
export interface SeededWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
agentURL: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an external workspace and wire it to the echo runtime.
|
||||
*/
|
||||
export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
|
||||
// 1. Create external workspace (no URL — platform will mint an auth token).
|
||||
const runId = Math.random().toString(36).slice(2, 8);
|
||||
const wsName = `Chat E2E Agent ${runId}`;
|
||||
const createRes = await fetch(`${PLATFORM_URL}/workspaces`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: wsName, tier: 1, external: true, runtime: "external" }),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const text = await createRes.text();
|
||||
throw new Error(`Failed to create workspace: ${createRes.status} ${text}`);
|
||||
}
|
||||
const ws = (await createRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
connection?: { auth_token?: string };
|
||||
};
|
||||
const authToken = ws.connection?.auth_token;
|
||||
if (!authToken) {
|
||||
throw new Error("Workspace created but no auth_token returned");
|
||||
}
|
||||
|
||||
// 2. Direct DB update: mark online + point url at echo runtime.
|
||||
// The platform blocks loopback URLs at the API layer (SSRF guard),
|
||||
// so we bypass via psql for local E2E.
|
||||
const dbUrl = process.env.E2E_DATABASE_URL;
|
||||
if (!dbUrl) {
|
||||
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
|
||||
}
|
||||
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
|
||||
const m = dbUrl.match(pgRegex);
|
||||
if (!m) {
|
||||
throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`);
|
||||
}
|
||||
const [, user, pass, host, port, db] = m;
|
||||
|
||||
// Pre-seed a platform_inbound_secret so chat file uploads don't trigger
|
||||
// the lazy-heal 503 "retry in 30 s" path on first use.
|
||||
const inboundSecret = Array.from({ length: 43 }, () =>
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[
|
||||
Math.floor(Math.random() * 64)
|
||||
],
|
||||
).join("");
|
||||
|
||||
const psql = [
|
||||
`PGPASSWORD=${pass} psql`,
|
||||
`-h ${host} -p ${port} -U ${user} -d ${db}`,
|
||||
`-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`,
|
||||
].join(" ");
|
||||
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(psql, { stdio: "pipe", timeout: 30_000 });
|
||||
} catch (err) {
|
||||
throw new Error(`DB update failed: ${err}`);
|
||||
}
|
||||
|
||||
return { id: ws.id, name: wsName, agentURL: echoURL, authToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a heartbeat interval that keeps an external workspace alive.
|
||||
* Returns a stop function.
|
||||
*/
|
||||
export function startHeartbeat(
|
||||
workspaceId: string,
|
||||
authToken: string,
|
||||
intervalMs = 30_000,
|
||||
): () => void {
|
||||
const send = () => {
|
||||
fetch(`${PLATFORM_URL}/registry/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspace_id: workspaceId,
|
||||
error_rate: 0,
|
||||
sample_error: "",
|
||||
active_tasks: 0,
|
||||
current_task: "",
|
||||
uptime_seconds: 0,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// Send immediately so the first heartbeat lands before the stale sweep.
|
||||
send();
|
||||
const timer = setInterval(send, intervalMs);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed chat-history rows for a workspace.
|
||||
*/
|
||||
export async function seedChatHistory(
|
||||
workspaceId: string,
|
||||
messages: Array<{ role: "user" | "agent"; content: string }>,
|
||||
): Promise<void> {
|
||||
const dbUrl = process.env.E2E_DATABASE_URL;
|
||||
if (!dbUrl) return;
|
||||
|
||||
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
|
||||
const m = dbUrl.match(pgRegex);
|
||||
if (!m) return;
|
||||
const [, user, pass, host, port, db] = m;
|
||||
|
||||
const values = messages
|
||||
.map(
|
||||
(msg, i) =>
|
||||
`('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`,
|
||||
)
|
||||
.join(",");
|
||||
|
||||
const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`;
|
||||
|
||||
const { execSync } = await import("node:child_process");
|
||||
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`;
|
||||
execSync(psql, { stdio: "pipe", timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a seeded workspace row directly from the DB.
|
||||
* Uses psql (same credentials as seedWorkspace) so we bypass any
|
||||
* workspace-server side-effects (container stop, cascade cleanup, etc.)
|
||||
* that can race or 500 on external workspaces.
|
||||
*/
|
||||
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
|
||||
const dbUrl = process.env.E2E_DATABASE_URL;
|
||||
if (!dbUrl) return;
|
||||
|
||||
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
|
||||
const m = dbUrl.match(pgRegex);
|
||||
if (!m) return;
|
||||
const [, user, pass, host, port, db] = m;
|
||||
|
||||
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`;
|
||||
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(psql, { stdio: "pipe", timeout: 30_000 });
|
||||
} catch {
|
||||
// Best-effort cleanup; don't fail the test suite if the row is already gone.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a workspace auth token so the canvas can make authenticated API
|
||||
* calls (WorkspaceAuth middleware).
|
||||
*/
|
||||
export async function mintTestToken(workspaceId: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to mint test token: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { auth_token: string };
|
||||
return data.auth_token;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Minimal A2A echo runtime for E2E tests.
|
||||
*
|
||||
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
|
||||
* requests, and returns a response with the original text echoed back.
|
||||
* Also implements the workspace-side chat upload ingest endpoint so
|
||||
* file-attachment E2E can exercise the full upload → send → echo
|
||||
* round-trip.
|
||||
*
|
||||
* Usage (inside test fixture):
|
||||
* const echo = await startEchoRuntime();
|
||||
* // ... seed workspace with agent_url pointing to echo.baseURL ...
|
||||
* echo.stop();
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
export interface EchoRuntime {
|
||||
baseURL: string;
|
||||
stop: () => Promise<void>;
|
||||
lastRequest: { method: string; text: string; files: unknown[] } | null;
|
||||
}
|
||||
|
||||
/** Parse a minimal multipart body and extract the first file's name + content. */
|
||||
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
|
||||
// Find the boundary line (first line starting with "--").
|
||||
const str = body.toString("binary");
|
||||
const firstDash = str.indexOf("--");
|
||||
if (firstDash === -1) return null;
|
||||
const eol = str.indexOf("\r\n", firstDash);
|
||||
if (eol === -1) return null;
|
||||
const boundary = str.slice(firstDash + 2, eol);
|
||||
const boundaryMarker = "\r\n--" + boundary;
|
||||
|
||||
// Find the first part that has a filename in Content-Disposition.
|
||||
let pos = eol + 2;
|
||||
while (pos < str.length) {
|
||||
const nextBoundary = str.indexOf(boundaryMarker, pos);
|
||||
if (nextBoundary === -1) break;
|
||||
const part = str.slice(pos, nextBoundary);
|
||||
|
||||
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
|
||||
if (cdMatch) {
|
||||
const name = cdMatch[1];
|
||||
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
|
||||
// Body starts after the first double-CRLF in the part.
|
||||
const bodyStart = part.indexOf("\r\n\r\n");
|
||||
if (bodyStart !== -1) {
|
||||
// Extract the raw bytes (not the string) so binary is safe.
|
||||
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
|
||||
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
|
||||
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
|
||||
const content = body.subarray(partStartInBody, partEndInBody);
|
||||
return { name, mimeType, content };
|
||||
}
|
||||
}
|
||||
pos = nextBoundary + boundaryMarker.length;
|
||||
// Skip trailing "--" (end marker) or CRLF.
|
||||
if (str.slice(pos, pos + 2) === "--") break;
|
||||
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function startEchoRuntime(): Promise<EchoRuntime> {
|
||||
let lastRequest: EchoRuntime["lastRequest"] = null;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
// CORS: allow the canvas origin (localhost:3000) to call us.
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url ?? "/";
|
||||
|
||||
// Workspace-side chat upload ingest (RFC #2312).
|
||||
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const file = parseMultipart(body);
|
||||
if (!file) {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: "no files field" }));
|
||||
return;
|
||||
}
|
||||
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
|
||||
const prefix = Array.from({ length: 32 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16),
|
||||
).join("");
|
||||
const response = {
|
||||
files: [
|
||||
{
|
||||
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
|
||||
name: sanitized,
|
||||
mimeType: file.mimeType,
|
||||
size: file.content.length,
|
||||
},
|
||||
],
|
||||
};
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify(response));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: A2A JSON-RPC handler.
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
try {
|
||||
const rpc = JSON.parse(body);
|
||||
const msg = rpc.params?.message;
|
||||
const textParts =
|
||||
msg?.parts
|
||||
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
|
||||
.map((p: { text?: string }) => p.text)
|
||||
.filter(Boolean) ?? [];
|
||||
const fileParts =
|
||||
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
|
||||
const text = textParts.join("\n");
|
||||
|
||||
lastRequest = {
|
||||
method: rpc.method ?? "unknown",
|
||||
text,
|
||||
files: fileParts,
|
||||
};
|
||||
|
||||
const replyText = text
|
||||
? `Echo: ${text}`
|
||||
: fileParts.length > 0
|
||||
? "Echo: received your file(s)."
|
||||
: "Echo: hello";
|
||||
|
||||
const response = {
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id ?? null,
|
||||
result: {
|
||||
parts: [{ kind: "text", text: replyText }],
|
||||
},
|
||||
};
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify(response));
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: "invalid json" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
return {
|
||||
baseURL,
|
||||
stop: () =>
|
||||
new Promise((resolve) => {
|
||||
server.close(() => resolve(undefined));
|
||||
}),
|
||||
get lastRequest() {
|
||||
return lastRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export default defineConfig({
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
|
||||
@@ -344,7 +344,7 @@ function ProviderPickerModal({
|
||||
// wrapper's bounds instead of the viewport.
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const allSaved = entries.every((e) => e.saved);
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime
|
||||
.replace(/[-_]/g, " ")
|
||||
@@ -616,7 +616,7 @@ function AllKeysModal({
|
||||
if (!open) return null;
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const allSaved = entries.every((e) => e.saved);
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime
|
||||
.replace(/[-_]/g, " ")
|
||||
|
||||
@@ -62,12 +62,21 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||
}
|
||||
setTheme(OPTIONS[next].value);
|
||||
// Move focus to the new button so arrow-key navigation is continuous.
|
||||
// Query is already scoped to radiogroup so no child-combinator needed;
|
||||
// avoids accidentally focusing unrelated [role=radio] elements
|
||||
// Use direct-child query to scope strictly to this radiogroup's buttons
|
||||
// and avoid accidentally focusing unrelated [role=radio] elements
|
||||
// elsewhere in the DOM (e.g. React Flow canvas nodes).
|
||||
// Guard: skip focus if the current target is no longer in the document
|
||||
// (e.g. React StrictMode double-invokes handlers during re-render).
|
||||
if (!e.currentTarget.isConnected) return;
|
||||
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
|
||||
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("[role=radio]");
|
||||
btns?.[next]?.focus();
|
||||
if (!radiogroup) return;
|
||||
// Use children[] instead of querySelectorAll("> [role=radio]") to avoid
|
||||
// jsdom's child-combinator selector parsing issues in test environments.
|
||||
const btns = Array.from(radiogroup.children).filter(
|
||||
(el): el is HTMLButtonElement =>
|
||||
el.tagName === "BUTTON" && el.getAttribute("role") === "radio"
|
||||
);
|
||||
if (next < btns.length) btns[next]?.focus();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -13,20 +13,17 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
/** Descendant count for the "N sub" badge — children are first-class nodes
|
||||
* rendered as full cards inside this one via React Flow's native parentId,
|
||||
* so we don't need to subscribe to the actual child list here.
|
||||
* Selecting `nodes` stably avoids a new selector reference on every store
|
||||
* update (React error #185 / Zustand + React 19 Object.is strictness). */
|
||||
* so we don't need to subscribe to the actual child list here. */
|
||||
function useDescendantCount(nodeId: string): number {
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
|
||||
return useCanvasStore(
|
||||
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
|
||||
);
|
||||
}
|
||||
|
||||
/** Boolean flag used to drive min-size and NodeResizer dimensions.
|
||||
* Selecting `nodes` stably avoids re-render loops (same issue as
|
||||
* useDescendantCount). */
|
||||
function useHasChildren(nodeId: string): boolean {
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
|
||||
return useCanvasStore(
|
||||
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
|
||||
);
|
||||
}
|
||||
|
||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||
|
||||
@@ -24,8 +24,12 @@ vi.mock("@/lib/theme-provider", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Wrap cleanup in act() so any pending React state updates (e.g. from
|
||||
// keyDown handlers that call setTheme) flush before DOM unmount. Without
|
||||
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
|
||||
// when the handleKeyDown callback tries to query the DOM mid-teardown.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
act(() => { cleanup(); });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -146,7 +150,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
const radios = screen.getAllByRole("radio");
|
||||
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
|
||||
act(() => { radios[2].focus(); });
|
||||
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
|
||||
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
@@ -160,7 +164,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
const radios = screen.getAllByRole("radio");
|
||||
// light (index 0) is current; ArrowLeft should go to dark (index 2)
|
||||
act(() => { radios[0].focus(); });
|
||||
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
@@ -174,7 +178,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
const radios = screen.getAllByRole("radio");
|
||||
// light (index 0) is current; ArrowDown should go to system (index 1)
|
||||
act(() => { radios[0].focus(); });
|
||||
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("system");
|
||||
});
|
||||
|
||||
@@ -187,7 +191,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
render(<ThemeToggle />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
act(() => { radios[2].focus(); });
|
||||
fireEvent.keyDown(radios[2], { key: "Home" });
|
||||
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
@@ -200,14 +204,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
render(<ThemeToggle />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
act(() => { radios[0].focus(); });
|
||||
fireEvent.keyDown(radios[0], { key: "End" });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
it("does nothing on unrelated keys", () => {
|
||||
render(<ThemeToggle />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
fireEvent.keyDown(radios[0], { key: "Enter" });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
|
||||
expect(mockSetTheme).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,20 +24,16 @@ import {
|
||||
*/
|
||||
export function DropTargetBadge() {
|
||||
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
|
||||
// Select nodes stably first — deriving targetName and childCount inside
|
||||
// the same selector creates a new return value on every store mutation
|
||||
// even when neither has changed (React error #185 / Zustand Object.is).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const targetName = (() => {
|
||||
if (!dragOverNodeId) return null;
|
||||
const n = nodes.find((nn) => nn.id === dragOverNodeId);
|
||||
const targetName = useCanvasStore((s) => {
|
||||
if (!s.dragOverNodeId) return null;
|
||||
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
|
||||
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
|
||||
})();
|
||||
const childCount = (() =>
|
||||
!dragOverNodeId
|
||||
});
|
||||
const childCount = useCanvasStore((s) =>
|
||||
!s.dragOverNodeId
|
||||
? 0
|
||||
: nodes.filter((n) => n.parentId === dragOverNodeId).length
|
||||
)();
|
||||
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
|
||||
);
|
||||
const { getInternalNode, flowToScreenPosition } = useReactFlow();
|
||||
if (!dragOverNodeId || !targetName) return null;
|
||||
const internal = getInternalNode(dragOverNodeId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { appendClass, removeClass } from "@/store/classNames";
|
||||
@@ -153,17 +153,10 @@ export function useCanvasViewport() {
|
||||
// fit, the user has to manually pan + zoom to find what they just
|
||||
// created. Only fires when TRANSITIONING from some-provisioning to
|
||||
// zero-provisioning — not on every re-render.
|
||||
//
|
||||
// Selecting `nodes` stably (array reference) avoids the
|
||||
// `.filter().length` anti-pattern which creates a new number on every
|
||||
// store update and breaks the wasProvisioning/hasProvisioning
|
||||
// transition detection (React error #185 / Zustand + React 19).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const provisioningCount = useMemo(
|
||||
() => nodes.filter((n) => n.data.status === "provisioning").length,
|
||||
[nodes],
|
||||
const provisioningCount = useCanvasStore(
|
||||
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
|
||||
);
|
||||
const nodeCount = nodes.length;
|
||||
const nodeCount = useCanvasStore((s) => s.nodes.length);
|
||||
|
||||
useEffect(() => {
|
||||
const hasProvisioning = provisioningCount > 0;
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
// that the desktop ChatTab uses, but with a slimmer surface: no
|
||||
// attachments, no A2A topology overlay, no conversation tracing.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
|
||||
import {
|
||||
useChatHistory,
|
||||
useChatSend,
|
||||
useChatSocket,
|
||||
} from "@/components/tabs/chat/hooks";
|
||||
|
||||
import { toMobileAgent } from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "agent" | "system";
|
||||
text: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const formatStoredTimestamp = (iso: string): string => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
@@ -29,171 +29,16 @@ const formatStoredTimestamp = (iso: string): string => {
|
||||
|
||||
type SubTab = "my" | "a2a";
|
||||
|
||||
function MarkdownBubble({
|
||||
children,
|
||||
dark,
|
||||
accent,
|
||||
}: {
|
||||
children: string;
|
||||
dark: boolean;
|
||||
accent: string;
|
||||
}) {
|
||||
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
|
||||
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
|
||||
const linkColor = accent;
|
||||
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: linkColor, textDecoration: "underline" }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
style={{
|
||||
background: codeBlockBg,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
overflow: "auto",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
margin: "4px 0",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className != null && String(className).length > 0;
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
background: codeBg,
|
||||
padding: "1px 4px",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
|
||||
strong: ({ children }) => (
|
||||
<strong style={{ fontWeight: 600 }}>{children}</strong>
|
||||
),
|
||||
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
|
||||
h1: ({ children }) => (
|
||||
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
style={{
|
||||
borderLeft: `2px solid ${quoteBorder}`,
|
||||
margin: "4px 0",
|
||||
paddingLeft: 8,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `0.5px solid ${quoteBorder}`,
|
||||
margin: "6px 0",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 13,
|
||||
margin: "4px 0",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th
|
||||
style={{
|
||||
border: `0.5px solid ${quoteBorder}`,
|
||||
padding: "4px 6px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td
|
||||
style={{
|
||||
border: `0.5px solid ${quoteBorder}`,
|
||||
padding: "4px 6px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
interface A2AResponseShape {
|
||||
result?: {
|
||||
parts?: Array<{ kind?: string; text?: string }>;
|
||||
};
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) =>
|
||||
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
|
||||
export function MobileChat({
|
||||
agentId,
|
||||
dark,
|
||||
@@ -204,40 +49,21 @@ export function MobileChat({
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
||||
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [tab, setTab] = useState<SubTab>("my");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(true);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Synchronous re-entry guard. `setSending(true)` schedules a state
|
||||
// update but doesn't flush before a second tap can fire send() — a ref
|
||||
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
|
||||
// double-send race a stale `sending` lets through.
|
||||
const sendInFlightRef = useRef(false);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
loading: historyLoading,
|
||||
loadError: historyError,
|
||||
loadInitial,
|
||||
appendMessageDeduped,
|
||||
} = useChatHistory(agentId);
|
||||
|
||||
const {
|
||||
sending,
|
||||
uploading,
|
||||
sendMessage,
|
||||
error: sendError,
|
||||
clearError,
|
||||
releaseSendGuards,
|
||||
} = useChatSend(agentId, {
|
||||
getHistoryMessages: () => messages,
|
||||
onUserMessage: appendMessageDeduped,
|
||||
onAgentMessage: appendMessageDeduped,
|
||||
});
|
||||
|
||||
useChatSocket(agentId, {
|
||||
onAgentMessage: appendMessageDeduped,
|
||||
onSendComplete: releaseSendGuards,
|
||||
});
|
||||
|
||||
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
||||
// shrinks when the user deletes text, then size to scrollHeight up to
|
||||
@@ -256,19 +82,73 @@ export function MobileChat({
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Consume any agent messages that arrived while history was loading.
|
||||
const initialConsumeDoneRef = useRef(false);
|
||||
// Load chat history on mount / agent switch.
|
||||
const loadHistory = useCallback(async () => {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const resp = await api.get<{
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}>(`/workspaces/${agentId}/chat-history?limit=50`);
|
||||
const loaded = (resp.messages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as "user" | "agent" | "system",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
}));
|
||||
setMessages(loaded);
|
||||
} catch (e) {
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load history");
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyLoading || initialConsumeDoneRef.current) return;
|
||||
initialConsumeDoneRef.current = true;
|
||||
let cancelled = false;
|
||||
loadHistory().then(() => {
|
||||
if (cancelled) return;
|
||||
// Consume any agent messages that arrived while history was loading.
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(agentId);
|
||||
if (msgs.length > 0) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
...msgs.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent" as const,
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})),
|
||||
]);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId, loadHistory]);
|
||||
|
||||
// Consume live agent pushes while the panel is mounted.
|
||||
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
useEffect(() => {
|
||||
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(agentId);
|
||||
for (const m of msgs) {
|
||||
appendMessageDeduped(
|
||||
createMessage("agent", m.content, m.attachments),
|
||||
);
|
||||
if (msgs.length > 0) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
...msgs.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent" as const,
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})),
|
||||
]);
|
||||
}
|
||||
}, [historyLoading, agentId, appendMessageDeduped]);
|
||||
}, [pendingAgentMsgs, agentId]);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
@@ -291,32 +171,58 @@ export function MobileChat({
|
||||
const a = toMobileAgent(node);
|
||||
const reachable = a.status === "online" || a.status === "degraded";
|
||||
|
||||
const onFilesPicked = (fileList: FileList | null) => {
|
||||
if (!fileList) return;
|
||||
const picked = Array.from(fileList);
|
||||
setPendingFiles((prev) => {
|
||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const removePendingFile = (index: number) =>
|
||||
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
|
||||
clearError();
|
||||
if (!text || sending || !reachable) return;
|
||||
if (sendInFlightRef.current) return;
|
||||
sendInFlightRef.current = true;
|
||||
setDraft("");
|
||||
const files = pendingFiles;
|
||||
setPendingFiles([]);
|
||||
await sendMessage(text, files);
|
||||
setError(null);
|
||||
setSending(true);
|
||||
const myMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
text,
|
||||
ts: formatTime(new Date()),
|
||||
};
|
||||
setMessages((m) => [...m, myMsg]);
|
||||
|
||||
try {
|
||||
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
messageId: crypto.randomUUID(),
|
||||
parts: [{ kind: "text", text }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const reply =
|
||||
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
|
||||
if (reply) {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "agent",
|
||||
text: reply,
|
||||
ts: formatTime(new Date()),
|
||||
},
|
||||
]);
|
||||
} else if (res.error?.message) {
|
||||
setError(res.error.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to send");
|
||||
} finally {
|
||||
setSending(false);
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-panel"
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
@@ -463,33 +369,8 @@ export function MobileChat({
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "14px 4px",
|
||||
textAlign: "center",
|
||||
color: p.failed,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
loadInitial();
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: 14,
|
||||
border: `0.5px solid ${p.failed}`,
|
||||
background: "transparent",
|
||||
color: p.failed,
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
{historyError}
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
|
||||
@@ -521,9 +402,7 @@ export function MobileChat({
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
<MarkdownBubble dark={dark} accent={p.accent}>
|
||||
{m.content}
|
||||
</MarkdownBubble>
|
||||
{m.text}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
@@ -532,13 +411,13 @@ export function MobileChat({
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{formatStoredTimestamp(m.timestamp)}
|
||||
{m.ts}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sendError && (
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
@@ -550,7 +429,7 @@ export function MobileChat({
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{sendError}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -581,60 +460,6 @@ export function MobileChat({
|
||||
backdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
{pendingFiles.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
marginBottom: 8,
|
||||
paddingLeft: 2,
|
||||
}}
|
||||
>
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div
|
||||
key={`${f.name}:${f.size}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 8px",
|
||||
borderRadius: 10,
|
||||
background: dark ? "#2a2823" : "#ece9e0",
|
||||
fontSize: 12,
|
||||
color: p.text2,
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePendingFile(i)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: p.text3,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -646,32 +471,21 @@ export function MobileChat({
|
||||
padding: "6px 6px 6px 12px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => onFilesPicked(e.target.files)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!reachable || sending || uploading}
|
||||
aria-label="Attach"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: p.text3,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: !reachable || sending || uploading ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{Icons.attach({ size: 16 })}
|
||||
@@ -717,32 +531,28 @@ export function MobileChat({
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
|
||||
disabled={!draft.trim() || !reachable || sending}
|
||||
aria-label="Send"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
|
||||
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
|
||||
flexShrink: 0,
|
||||
background:
|
||||
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
|
||||
draft.trim() && reachable && !sending
|
||||
? p.accent
|
||||
: dark
|
||||
? "#2a2823"
|
||||
: "#ece9e0",
|
||||
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
|
||||
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<span style={{ fontSize: 10, fontWeight: 600 }}>↑</span>
|
||||
) : (
|
||||
Icons.send({ size: 16 })
|
||||
)}
|
||||
{Icons.send({ size: 16 })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@@ -32,10 +32,7 @@ export function MobileDetail({
|
||||
onChat: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
|
||||
// creates a new return value on every store update (React error #185).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
||||
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||
const [tab, setTab] = useState<TabId>("overview");
|
||||
|
||||
if (!node) {
|
||||
@@ -214,7 +211,6 @@ export function MobileDetail({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChat}
|
||||
data-testid="mobile-chat-cta"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
|
||||
import { tierCode } from "./palette";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
|
||||
@@ -26,6 +27,7 @@ const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
|
||||
|
||||
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
|
||||
const p = usePalette(dark);
|
||||
const isSaaS = isSaaSTenant();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(true);
|
||||
const [tplId, setTplId] = useState<string | null>(null);
|
||||
@@ -43,7 +45,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
setTemplates(list);
|
||||
if (list.length > 0) {
|
||||
setTplId(list[0].id);
|
||||
setTier(tierCode(list[0].tier));
|
||||
setTier(isSaaS ? "T4" : tierCode(list[0].tier));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -55,7 +57,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [isSaaS]);
|
||||
|
||||
const handleSpawn = async () => {
|
||||
if (busy || !tplId) return;
|
||||
@@ -67,7 +69,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
await api.post<{ id: string }>("/workspaces", {
|
||||
name: (name.trim() || chosen.name),
|
||||
template: chosen.id,
|
||||
tier: Number(tier.slice(1)),
|
||||
tier: isSaaS ? 4 : Number(tier.slice(1)),
|
||||
canvas: {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
@@ -203,7 +205,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
>
|
||||
{templates.map((t) => {
|
||||
const on = tplId === t.id;
|
||||
const tCode = tierCode(t.tier);
|
||||
const tCode = isSaaS ? "T4" : tierCode(t.tier);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
|
||||
@@ -8,19 +8,11 @@
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileChat } from "../MobileChat";
|
||||
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
// vi.mock without a factory auto-mocks the module. In tests, we configure
|
||||
// api.get / api.post directly (they are vi.fn() from the auto-mock).
|
||||
// Tests that need specific behaviour use mockResolvedValueOnce on the
|
||||
// auto-mocked functions.
|
||||
vi.mock("@/lib/api");
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAgentId = "ws-chat-test";
|
||||
@@ -36,18 +28,16 @@ const mockStoreState = {
|
||||
height?: number;
|
||||
}>,
|
||||
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
|
||||
consumeAgentMessages: () => [],
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((sel?: (state: typeof mockStoreState) => unknown) => {
|
||||
if (sel) return sel(mockStoreState);
|
||||
return mockStoreState;
|
||||
}),
|
||||
vi.fn((sel) => sel(mockStoreState)),
|
||||
{
|
||||
getState: () => mockStoreState,
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
getState: () => ({
|
||||
...mockStoreState,
|
||||
consumeAgentMessages: vi.fn(() => []),
|
||||
}),
|
||||
},
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
||||
@@ -69,6 +59,20 @@ vi.mock("@/store/canvas", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { mockApiPost } = vi.hoisted(() => ({
|
||||
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
|
||||
}));
|
||||
|
||||
const { mockApiGet } = vi.hoisted(() => ({
|
||||
mockApiGet: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockApiGet, post: mockApiPost },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const onlineNode = {
|
||||
@@ -153,17 +157,10 @@ function renderChat(agentId: string, dark = false) {
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnBack.mockClear();
|
||||
mockApiGet.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.agentMessages = {};
|
||||
// Set up spies on the real api methods. Tests override these per-call.
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
const postSpy = vi.spyOn(api, "post");
|
||||
getSpy.mockResolvedValue({ messages: [], reached_end: true });
|
||||
postSpy.mockResolvedValue({ result: { parts: [] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockApiPost.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -280,26 +277,18 @@ describe("MobileChat — empty state", () => {
|
||||
});
|
||||
|
||||
it('shows "Send a message to start chatting." when no messages', async () => {
|
||||
// History fetch resolves immediately in tests (mockResolvedValue).
|
||||
// act() flushes the microtask queue so the component reaches its
|
||||
// post-load state before we assert.
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
const { container } = renderChat(mockAgentId);
|
||||
await waitFor(() =>
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting."),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => {
|
||||
// Explicitly set to empty to simulate no stored messages
|
||||
mockStoreState.agentMessages = {};
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
const { container } = renderChat(mockAgentId);
|
||||
await waitFor(() =>
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting."),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,132 +334,3 @@ describe("MobileChat — dark mode", () => {
|
||||
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Chat history loading ────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — chat history", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("calls GET /workspaces/:id/chat-history on mount", async () => {
|
||||
await act(async () => {
|
||||
renderChat(mockAgentId);
|
||||
});
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows loading state while history is fetching", () => {
|
||||
// Do NOT await — check the pre-resolve state.
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.textContent ?? "").toContain("Loading chat history…");
|
||||
});
|
||||
|
||||
it("shows empty state after history resolves with no messages", async () => {
|
||||
// beforeEach already sets api.get to resolve with empty — no override needed.
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
|
||||
it("renders messages from history response", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
content: "Hello agent",
|
||||
timestamp: "2026-04-25T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
role: "agent",
|
||||
content: "Hello back",
|
||||
timestamp: "2026-04-25T10:00:01Z",
|
||||
},
|
||||
],
|
||||
reached_end: true,
|
||||
});
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Hello agent");
|
||||
expect(container.textContent ?? "").toContain("Hello back");
|
||||
});
|
||||
|
||||
it("maps user role from API correctly", async () => {
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-u",
|
||||
role: "user",
|
||||
content: "user message",
|
||||
timestamp: "2026-04-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
reached_end: true,
|
||||
});
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
// User messages render right-aligned. The text content check is sufficient
|
||||
// to confirm the message appeared.
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("user message");
|
||||
});
|
||||
|
||||
it("shows error state when history fetch fails", async () => {
|
||||
vi.spyOn(api, "get").mockRejectedValue(new Error("Network error"));
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
expect(container.textContent ?? "").toContain("Could not load chat history.");
|
||||
expect(container.textContent ?? "").toContain("Retry");
|
||||
});
|
||||
|
||||
it("Retry button re-fetches history after error", async () => {
|
||||
// Make the initial mount call fail so the Retry button appears, then
|
||||
// make the retry call succeed so we can verify the full flow.
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
getSpy
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce({ messages: [], reached_end: true });
|
||||
|
||||
let renderResult: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
renderResult = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = renderResult!;
|
||||
|
||||
// Error state should be shown with Retry button.
|
||||
expect(container.textContent ?? "").toContain("Could not load chat history.");
|
||||
expect(container.textContent ?? "").toContain("Retry");
|
||||
|
||||
// Click Retry — the button's onClick fires api.get again.
|
||||
// The second mockResolvedValueOnce makes it succeed.
|
||||
const retryBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Retry",
|
||||
);
|
||||
expect(retryBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
retryBtn?.click();
|
||||
});
|
||||
|
||||
// waitFor polls until the retry resolves and component re-renders.
|
||||
await waitFor(() => {
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
// Initial call + retry = 2.
|
||||
expect(getSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,7 +288,6 @@ export function AgentCard({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-card"
|
||||
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
|
||||
@@ -16,40 +16,7 @@ interface TokensTabProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// The settings panel passes the literal sentinel "global" when no canvas
|
||||
// node is selected. Workspace tokens are inherently per-workspace — there
|
||||
// is no /workspaces/global/tokens endpoint (querying the uuid column with
|
||||
// "global" 500s on Postgres). The org-wide equivalent lives in the
|
||||
// separate "Org API Keys" tab. Mirrors the sentinel-awareness that
|
||||
// api/secrets.ts already has (workspaceId === 'global' → /settings/secrets).
|
||||
const GLOBAL_WORKSPACE_ID = 'global';
|
||||
|
||||
export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
if (workspaceId === GLOBAL_WORKSPACE_ID) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5">
|
||||
Bearer tokens for authenticating API calls to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-ink-mid">Select a workspace node first</p>
|
||||
<p className="text-[10px] text-ink-mid mt-1">
|
||||
Workspace tokens are scoped to a single workspace. Select a node
|
||||
on the canvas to manage its tokens, or use the{' '}
|
||||
<span className="text-accent font-medium">Org API Keys</span> tab
|
||||
for org-wide API keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <WorkspaceTokensTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
const [tokens, setTokens] = useState<Token[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
@@ -302,35 +302,3 @@ describe("TokensTab — error", () => {
|
||||
expect(document.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── "global" sentinel (no node selected) ────────────────────────────────────
|
||||
//
|
||||
// Regression: SettingsPanel passes the literal "global" when no canvas
|
||||
// node is selected. workspace tokens are per-workspace and there is no
|
||||
// /workspaces/global/tokens endpoint — calling it 500'd
|
||||
// ("invalid input syntax for type uuid: global"). The tab must NOT call
|
||||
// the API in that state and must point the user at the Org API Keys tab.
|
||||
describe("TokensTab — global sentinel (no node selected)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockApiGet.mockRejectedValue(new Error("should not be called"));
|
||||
});
|
||||
|
||||
it("does not call the API and shows a pointer to Org API Keys", async () => {
|
||||
render(<TokensTab workspaceId="global" />);
|
||||
await flush();
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Select a workspace node");
|
||||
expect(document.body.textContent).toContain("Org API Keys");
|
||||
// No error banner, no scary 500 surfacing.
|
||||
expect(document.querySelector(".text-bad")).toBeNull();
|
||||
});
|
||||
|
||||
it("has no create button in the global state", async () => {
|
||||
render(<TokensTab workspaceId="global" />);
|
||||
await flush();
|
||||
expect(document.body.textContent).not.toContain("New Token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
data-testid="budget-save-btn"
|
||||
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
|
||||
@@ -255,7 +255,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition"
|
||||
>
|
||||
{showForm ? "Cancel" : "+ Connect"}
|
||||
</button>
|
||||
@@ -308,7 +308,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formValues["bot_token"]}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40"
|
||||
>
|
||||
{discovering ? "Detecting..." : "Detect Chats"}
|
||||
</button>
|
||||
|
||||
@@ -5,19 +5,16 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
|
||||
import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
||||
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
||||
import { PendingAttachmentPill } from "./chat/AttachmentViews";
|
||||
import { AttachmentPreview } from "./chat/AttachmentPreview";
|
||||
import { extractFilesFromTask } from "./chat/message-parser";
|
||||
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
|
||||
import { appendActivityLine } from "./chat/activityLog";
|
||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { useChatHistory } from "./chat/hooks/useChatHistory";
|
||||
import { useChatSend } from "./chat/hooks/useChatSend";
|
||||
import { useChatSocket } from "./chat/hooks/useChatSocket";
|
||||
|
||||
export { extractReplyText } from "./chat/hooks/useChatSend";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -26,6 +23,147 @@ interface Props {
|
||||
|
||||
type ChatSubTab = "my-chat" | "agent-comms";
|
||||
|
||||
// A2A response shape (subset). The full schema is in @a2a-js/sdk but we only
|
||||
// need parts/artifacts text + file extraction for the synchronous fallback.
|
||||
interface A2AFileRef {
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
bytes?: string;
|
||||
size?: number;
|
||||
}
|
||||
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
|
||||
// Pydantic union (TextPart | FilePart | DataPart). The flat
|
||||
// protobuf shape `{url, filename, mediaType}` is rejected at the
|
||||
// request boundary with `Field required` errors — keep this
|
||||
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
|
||||
interface A2APart {
|
||||
kind: string;
|
||||
text?: string;
|
||||
file?: A2AFileRef;
|
||||
}
|
||||
interface A2AResponse {
|
||||
result?: {
|
||||
parts?: A2APart[];
|
||||
artifacts?: Array<{ parts: A2APart[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
// Internal-self-message filtering moved server-side in RFC #2945
|
||||
// PR-C/D — the platform's /chat-history endpoint applies the
|
||||
// IsInternalSelfMessage predicate before returning rows, so the
|
||||
// client no longer needs the local backstop on the history path.
|
||||
// The proper fix is still X-Workspace-ID header (source_id=workspace_id);
|
||||
// the platform-side prefix filter handles the residual cases.
|
||||
|
||||
// extractReplyText pulls the agent's text reply out of an A2A response.
|
||||
// Concatenates ALL text parts (joined with "\n") rather than returning
|
||||
// just the first. Claude Code and other runtimes commonly emit multi-
|
||||
// part text replies for long content (markdown tables, code blocks),
|
||||
// and the prior "first part wins" implementation silently truncated
|
||||
// the rest — observed on a 15k-char Wave 1 brief that rendered only
|
||||
// the table header. Mirrors extractTextsFromParts in message-parser.ts.
|
||||
//
|
||||
// Server-side counterpart in workspace-server/internal/channels/
|
||||
// manager.go has the same single-part bug; fix that too if/when a
|
||||
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
return parts
|
||||
.filter((p) => p.kind === "text")
|
||||
.map((p) => p.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
};
|
||||
const result = resp?.result;
|
||||
const collected: string[] = [];
|
||||
const fromParts = collect(result?.parts);
|
||||
if (fromParts) collected.push(fromParts);
|
||||
// Walk artifacts even if parts had text — some producers (Hermes
|
||||
// tool calls) emit a summary in parts AND details in artifacts.
|
||||
// Returning early on parts dropped the artifact body silently.
|
||||
if (result?.artifacts) {
|
||||
for (const a of result.artifacts) {
|
||||
const t = collect(a.parts);
|
||||
if (t) collected.push(t);
|
||||
}
|
||||
}
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
// Agent-returned files live on the same response shape as text —
|
||||
// delegated to extractFilesFromTask in message-parser.ts, which also
|
||||
// walks status.message.parts (that ChatTab's legacy text extractor
|
||||
// doesn't). Single source of truth for file-part parsing across
|
||||
// live chat, activity log replay, and any future consumers.
|
||||
|
||||
/** Initial chat history page size. The newest N messages are rendered
|
||||
* on first paint; older history is fetched on demand via loadOlder()
|
||||
* when the user scrolls the top sentinel into view. */
|
||||
const INITIAL_HISTORY_LIMIT = 10;
|
||||
/** Subsequent older-history batch size. Larger than INITIAL so a long
|
||||
* scroll-back doesn't fan out into many round-trips. */
|
||||
const OLDER_HISTORY_BATCH = 20;
|
||||
|
||||
/**
|
||||
* Load chat history from the platform's typed /chat-history endpoint.
|
||||
*
|
||||
* Server-side rendering of activity_logs rows into ChatMessage shape
|
||||
* lives in workspace-server/internal/messagestore/postgres_store.go
|
||||
* (RFC #2945 PR-C/D). The server already applies the canvas-source
|
||||
* filter, the internal-self-message predicate, the role decision
|
||||
* (status=error vs agent-error prefix → system), and the v0/v1
|
||||
* file-shape extraction. Canvas just renders what it receives.
|
||||
*
|
||||
* Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
|
||||
*
|
||||
* GET /workspaces/:id/chat-history?limit=N&before_ts=T
|
||||
* 200 → {"messages": ChatMessage[], "reached_end": boolean}
|
||||
*
|
||||
* Pagination:
|
||||
* - Pass `limit` to bound the page size (newest-first from server).
|
||||
* - Pass `beforeTs` (RFC3339) to fetch rows STRICTLY OLDER than that
|
||||
* timestamp. Combined with limit, this yields the next-older page
|
||||
* when scrolling backward through history.
|
||||
*
|
||||
* `reachedEnd` is propagated from the server. The server computes it
|
||||
* by comparing rowCount vs limit so a partial last page is correctly
|
||||
* detected even when the row→bubble fan-out is non-1:1 (each row
|
||||
* produces 1-2 bubbles).
|
||||
*/
|
||||
async function loadMessagesFromDB(
|
||||
workspaceId: string,
|
||||
limit: number,
|
||||
beforeTs?: string,
|
||||
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (beforeTs) params.set("before_ts", beforeTs);
|
||||
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
|
||||
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
|
||||
);
|
||||
|
||||
// Server emits oldest-first within the page (RFC #2945 PR-C-2
|
||||
// post-fix: server reverses row-aware before returning so the
|
||||
// wire is display-ready). Canvas appends/prepends without
|
||||
// reordering — this avoids the pair-flip bug a naive flat
|
||||
// reverse causes when each row produces a (user, agent) pair
|
||||
// with the same timestamp.
|
||||
return {
|
||||
messages: resp.messages ?? [],
|
||||
error: null,
|
||||
reachedEnd: resp.reached_end,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
messages: [],
|
||||
error: err instanceof Error ? err.message : "Failed to load chat history",
|
||||
reachedEnd: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel.
|
||||
*/
|
||||
@@ -33,7 +171,7 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
const [subTab, setSubTab] = useState<ChatSubTab>("my-chat");
|
||||
|
||||
return (
|
||||
<div data-testid="chat-panel" className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -109,68 +247,268 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
|
||||
*/
|
||||
function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [activityLog, setActivityLog] = useState<string[]>([]);
|
||||
// `sending` is strictly the "this tab kicked off a send and hasn't
|
||||
// seen the reply yet" signal. Previously this was initialized from
|
||||
// data.currentTask to pick up in-flight agent work on mount, but
|
||||
// that conflated agent-busy (workspace heartbeat) with user-
|
||||
// in-flight (local send): when the WS dropped a TASK_COMPLETE event,
|
||||
// currentTask lingered, the component re-mounted with sending=true,
|
||||
// and the Send button stayed disabled forever even though nothing
|
||||
// local was in flight. For the "agent is busy, show spinner" UX,
|
||||
// use data.currentTask directly in the render path.
|
||||
const [sending, setSending] = useState(false);
|
||||
const [thinkingElapsed, setThinkingElapsed] = useState(0);
|
||||
const [activityLog, setActivityLog] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const currentTaskRef = useRef(data.currentTask);
|
||||
const sendingFromAPIRef = useRef(false);
|
||||
const [agentReachable, setAgentReachable] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
// First-mount scroll-to-bottom needs `behavior: "instant"` — long
|
||||
// conversations smooth-animate for ~300ms which any concurrent
|
||||
// re-render can interrupt, leaving the user stuck mid-conversation
|
||||
// when the chat tab opens. Subsequent appends (new agent messages)
|
||||
// keep `smooth` for the visual "landing" feel. Flipped the first
|
||||
// time messages.length goes positive, so a workspace switch (which
|
||||
// remounts ChatTab) gets a fresh instant jump too.
|
||||
const hasInitialScrollRef = useRef(false);
|
||||
// Lazy-load older history on scroll-up.
|
||||
// - containerRef = the scrollable messages viewport
|
||||
// - topRef = sentinel above the messages list; IO observes it
|
||||
// and triggers loadOlder() when it enters view
|
||||
// - hasMore = false once a fetch returns < limit rows; stops IO
|
||||
// - loadingOlder = drives the "Loading older messages…" UI label
|
||||
// - inflightRef = synchronous guard against double-entry of loadOlder
|
||||
// when the IO callback fires twice in the same
|
||||
// microtask (state-based guard would be stale until
|
||||
// the next React commit)
|
||||
// - scrollAnchorRef = saves distance-from-bottom before a prepend
|
||||
// so the useLayoutEffect below can restore the
|
||||
// user's exact viewport position. Without this,
|
||||
// prepending older messages would jump the scroll
|
||||
// position by the height of the new content.
|
||||
// - oldestMessageRef / hasMoreRef = let the loadOlder closure read
|
||||
// the latest values without taking them as deps —
|
||||
// every live agent push mutates `messages`, and
|
||||
// having loadOlder depend on `messages` would tear
|
||||
// down + re-arm the IntersectionObserver on every
|
||||
// push. Refs decouple the observer lifecycle from
|
||||
// message-list updates.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const hasInitialScrollRef = useRef(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const inflightRef = useRef(false);
|
||||
// The scroll anchor includes the first-message id as it was BEFORE
|
||||
// the prepend — see useLayoutEffect below for why. Without this tag,
|
||||
// a live agent push that appends WHILE loadOlder is in flight would
|
||||
// run useLayoutEffect against the append (anchor still set), the
|
||||
// "restore" math would scroll the user to a stale offset, AND the
|
||||
// append's normal scroll-to-bottom would be swallowed.
|
||||
const scrollAnchorRef = useRef<
|
||||
{ savedDistanceFromBottom: number; expectFirstIdNotEqual: string | null } | null
|
||||
>(null);
|
||||
const oldestMessageRef = useRef<ChatMessage | null>(null);
|
||||
const hasMoreRef = useRef(true);
|
||||
// Monotonic token bumped on workspace switch + on every loadOlder
|
||||
// entry. Each fetch's .then() captures its own token; if the token
|
||||
// has moved, the resolved messages belong to a stale workspace or a
|
||||
// superseded fetch and we silently drop them. Without this guard, a
|
||||
// workspace switch mid-fetch would have the in-flight promise
|
||||
// resolve into the new workspace's setMessages — the user sees
|
||||
// someone else's history briefly.
|
||||
const fetchTokenRef = useRef(0);
|
||||
// Files the user has picked but not yet sent. Cleared on send
|
||||
// (upload success) or by the × on each pill.
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const pasteCounterRef = useRef(0);
|
||||
// Guard against a double-click during the upload phase: React
|
||||
// state updates from the click that started the upload haven't
|
||||
// flushed yet, so the disabled-button logic sees `uploading=false`
|
||||
// from the closure and lets a second `sendMessage` enter. A ref
|
||||
// observes the latest value synchronously.
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Monotonic token bumped on every sendMessage entry. Each .then()/
|
||||
// .catch() captures its own token in closure and bails if a newer
|
||||
// send has superseded it — prevents a late HTTP response for an
|
||||
// earlier message from clobbering the flags / appending text that
|
||||
// belong to a newer in-flight send. Race scenario the token closes:
|
||||
// (1) send msg #1 (2) WS push for msg #1 arrives, releases guards
|
||||
// (3) user sends msg #2 (4) HTTP for msg #1 finally lands — without
|
||||
// the token check, .then() sees sendingFromAPIRef=true (set by
|
||||
// msg #2's send), enters the main body, and processes msg #1's body
|
||||
// as if it were msg #2's reply.
|
||||
const sendTokenRef = useRef(0);
|
||||
|
||||
const history = useChatHistory(workspaceId, containerRef);
|
||||
const chatSend = useChatSend(workspaceId, {
|
||||
getHistoryMessages: () => history.messages,
|
||||
onUserMessage: (msg) => history.setMessages((prev) => [...prev, msg]),
|
||||
onAgentMessage: (msg) => history.setMessages((prev) => appendMessageDeduped(prev, msg)),
|
||||
});
|
||||
const { sending, uploading, sendMessage, error: sendError, clearError: clearSendError, releaseSendGuards, sendingFromAPIRef } = chatSend;
|
||||
// Release every in-flight send guard at once. Used by every site
|
||||
// that ends a send: pendingAgentMsgs WS push, ACTIVITY_LOGGED
|
||||
// a2a_receive ok/error WS event, HTTP .then() success, and HTTP
|
||||
// .catch() success. Keep these in lockstep — a future contributor
|
||||
// adding a new "I saw the reply" path that only clears `sending` +
|
||||
// `sendingFromAPIRef` (the natural pair) silently re-introduces
|
||||
// the post-WS Send-button freeze, because the disabled-button
|
||||
// logic can't see `sendInFlightRef` and so the visible state diverges
|
||||
// from the synchronous re-entry guard at line 464.
|
||||
const releaseSendGuards = useCallback(() => {
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
}, []);
|
||||
|
||||
const displayError = error || sendError;
|
||||
// Initial-load fetch — used by the mount effect and the "Retry"
|
||||
// button below. Single source of truth so the two paths can't drift
|
||||
// (e.g. INITIAL_HISTORY_LIMIT bumped in the effect but not the
|
||||
// retry, leading to inconsistent first-paint sizes).
|
||||
const loadInitial = useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setHasMore(true);
|
||||
// Bump the token; any in-flight fetch from the previous workspace
|
||||
// (or a previous retry) will see token != myToken in its .then()
|
||||
// and silently bail — the late response can't clobber the new
|
||||
// workspace's state.
|
||||
fetchTokenRef.current += 1;
|
||||
const myToken = fetchTokenRef.current;
|
||||
loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
|
||||
({ messages: msgs, error: fetchErr, reachedEnd }) => {
|
||||
if (fetchTokenRef.current !== myToken) return;
|
||||
setMessages(msgs);
|
||||
setLoadError(fetchErr);
|
||||
setHasMore(!reachedEnd);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
}, [workspaceId]);
|
||||
|
||||
useChatSocket(workspaceId, {
|
||||
onAgentMessage: (msg) => {
|
||||
history.setMessages((prev) => appendMessageDeduped(prev, msg));
|
||||
if (sendingFromAPIRef.current) {
|
||||
releaseSendGuards();
|
||||
// Load chat history on mount / workspace switch.
|
||||
// Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
|
||||
// rest streams in as the user scrolls up via loadOlder() below. Pre-
|
||||
// 2026-05-05 this fetched the newest 50 in one shot; on a long-running
|
||||
// workspace that meant 50× message-bubble paint + DOM cost on every
|
||||
// tab-open even when the user only wanted to read the last few.
|
||||
useEffect(() => {
|
||||
loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
// Mirror the latest oldest-message + hasMore into refs so loadOlder
|
||||
// can read them without taking `messages` as a dep. Every live push
|
||||
// through agentMessages would otherwise recreate loadOlder and tear
|
||||
// down the IO observer.
|
||||
useEffect(() => {
|
||||
oldestMessageRef.current = messages[0] ?? null;
|
||||
}, [messages]);
|
||||
useEffect(() => {
|
||||
hasMoreRef.current = hasMore;
|
||||
}, [hasMore]);
|
||||
|
||||
// Fetch the next-older batch and prepend. Stable identity (deps =
|
||||
// [workspaceId]) so the IntersectionObserver effect below doesn't
|
||||
// re-arm on every messages update.
|
||||
const loadOlder = useCallback(async () => {
|
||||
// inflightRef is the load-bearing guard — synchronous, set BEFORE
|
||||
// any await, so two IO callbacks dispatched in the same microtask
|
||||
// can't both pass. The state checks are defensive secondary
|
||||
// gates for the slow-scroll case.
|
||||
if (inflightRef.current || !hasMoreRef.current) return;
|
||||
const oldest = oldestMessageRef.current;
|
||||
if (!oldest) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
inflightRef.current = true;
|
||||
// Capture the user's distance-from-bottom BEFORE we prepend so the
|
||||
// useLayoutEffect can restore it after the new DOM lands. The
|
||||
// expectFirstIdNotEqual tag is what the layout effect checks
|
||||
// against `messages[0].id` to disambiguate prepend (id changed) vs
|
||||
// append (id unchanged → live message landed mid-fetch). Without
|
||||
// it, an agent push during loadOlder runs the "restore" against a
|
||||
// stale anchor — user gets yanked + the append's bottom-pin is
|
||||
// swallowed.
|
||||
scrollAnchorRef.current = {
|
||||
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
|
||||
expectFirstIdNotEqual: oldest.id,
|
||||
};
|
||||
fetchTokenRef.current += 1;
|
||||
const myToken = fetchTokenRef.current;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const { messages: older, reachedEnd } = await loadMessagesFromDB(
|
||||
workspaceId,
|
||||
OLDER_HISTORY_BATCH,
|
||||
oldest.timestamp,
|
||||
);
|
||||
// Workspace switched (or another loadOlder bumped the token)
|
||||
// mid-fetch — drop these results, they belong to a stale tab.
|
||||
if (fetchTokenRef.current !== myToken) {
|
||||
scrollAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
},
|
||||
onActivityLog: (entry) => {
|
||||
if (!sending) return;
|
||||
setActivityLog((prev) => appendActivityLine(prev, entry));
|
||||
},
|
||||
onSendComplete: () => {
|
||||
if (sendingFromAPIRef.current) {
|
||||
releaseSendGuards();
|
||||
if (older.length > 0) {
|
||||
setMessages((prev) => [...older, ...prev]);
|
||||
} else {
|
||||
// Nothing came back — clear the anchor so the next paint doesn't
|
||||
// try to "restore" against a no-op prepend.
|
||||
scrollAnchorRef.current = null;
|
||||
}
|
||||
},
|
||||
onSendError: (err) => {
|
||||
if (sendingFromAPIRef.current) {
|
||||
releaseSendGuards();
|
||||
setError(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
setHasMore(!reachedEnd);
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
inflightRef.current = false;
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
// IntersectionObserver on the top sentinel. Fires loadOlder() the
|
||||
// moment the user scrolls within 200px of the top. AbortController
|
||||
// unwires cleanly on workspace switch / unmount; root is the
|
||||
// scrollable container so we observe only what's visible inside it.
|
||||
//
|
||||
// Dependencies:
|
||||
// - loadOlder — stable per workspaceId (refs decouple it from
|
||||
// message updates), so this dep is here for the
|
||||
// workspace-switch case only
|
||||
// - hasMore — re-run when older history runs out so we
|
||||
// disconnect cleanly
|
||||
// - hasMessages — load-bearing: the sentinel JSX is gated on
|
||||
// `messages.length > 0`, so topRef.current is null
|
||||
// on the empty-messages render. We re-arm exactly
|
||||
// once when messages first land. NOT depending on
|
||||
// `messages.length` (or `messages`) directly so
|
||||
// each subsequent message append doesn't tear down
|
||||
// + re-arm the observer.
|
||||
const hasMessages = messages.length > 0;
|
||||
useEffect(() => {
|
||||
const top = topRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!top || !container) return;
|
||||
if (!hasMore) return; // stop observing when no older history exists
|
||||
const ac = new AbortController();
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (ac.signal.aborted) return;
|
||||
if (entries[0]?.isIntersecting) loadOlder();
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
|
||||
);
|
||||
io.observe(top);
|
||||
ac.signal.addEventListener("abort", () => io.disconnect());
|
||||
return () => ac.abort();
|
||||
}, [loadOlder, hasMore, hasMessages]);
|
||||
|
||||
// Agent reachability
|
||||
useEffect(() => {
|
||||
const reachable = data.status === "online" || data.status === "degraded";
|
||||
setAgentReachable(reachable);
|
||||
if (reachable) {
|
||||
setError(null);
|
||||
clearSendError();
|
||||
} else {
|
||||
setError(`Agent is ${data.status}`);
|
||||
}
|
||||
}, [data.status, clearSendError]);
|
||||
setError(reachable ? null : `Agent is ${data.status}`);
|
||||
}, [data.status]);
|
||||
|
||||
useEffect(() => {
|
||||
currentTaskRef.current = data.currentTask;
|
||||
}, [data.currentTask]);
|
||||
|
||||
// Scroll behavior across messages updates:
|
||||
// - Prepend (loadOlder landed) → restore the user's saved
|
||||
@@ -180,24 +518,71 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
// paint — otherwise the user sees the page jump for one frame.
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const anchor = history.scrollAnchorRef.current;
|
||||
const anchor = scrollAnchorRef.current;
|
||||
// Only honor the anchor when this messages-update is the prepend
|
||||
// we expected. messages[0].id is the test:
|
||||
// - prepend → messages[0] is one of the older rows → id !== expectFirstIdNotEqual
|
||||
// - append → messages[0] unchanged → id === expectFirstIdNotEqual → fall through
|
||||
// Without this check, an agent push that lands mid-loadOlder would
|
||||
// run the restore against the append's update, yank the user's
|
||||
// scroll, AND swallow the append's bottom-pin.
|
||||
if (
|
||||
anchor &&
|
||||
container &&
|
||||
history.messages.length > 0 &&
|
||||
history.messages[0].id !== anchor.expectFirstIdNotEqual
|
||||
messages.length > 0 &&
|
||||
messages[0].id !== anchor.expectFirstIdNotEqual
|
||||
) {
|
||||
container.scrollTop = container.scrollHeight - anchor.savedDistanceFromBottom;
|
||||
history.scrollAnchorRef.current = null;
|
||||
scrollAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!hasInitialScrollRef.current && history.messages.length > 0) {
|
||||
// Instant on first arrival of messages — smooth-scroll on a long
|
||||
// conversation gets interrupted by concurrent renders and leaves
|
||||
// the user stuck in the middle. After the first jump, subsequent
|
||||
// appends animate as before.
|
||||
if (!hasInitialScrollRef.current && messages.length > 0) {
|
||||
hasInitialScrollRef.current = true;
|
||||
bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
|
||||
return;
|
||||
}
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [history.messages, history.scrollAnchorRef]);
|
||||
}, [messages]);
|
||||
|
||||
// Consume agent push messages (send_message_to_user) from global store.
|
||||
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
|
||||
// than the /a2a HTTP response — when that happens, the push is the
|
||||
// authoritative "reply arrived" signal for the UI, so clear `sending`
|
||||
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
|
||||
// whichever path clears first wins.
|
||||
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
|
||||
useEffect(() => {
|
||||
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(workspaceId);
|
||||
for (const m of msgs) {
|
||||
// Dedupe in case the agent proactively pushed the same text the
|
||||
// HTTP /a2a response already delivered (observed with the Hermes
|
||||
// runtime, which emits both a reply body and a send_message_to_user
|
||||
// push for the same content). Attachments ride along with the
|
||||
// message so files returned by the A2A_RESPONSE WS path render
|
||||
// their download chips.
|
||||
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content, m.attachments)));
|
||||
}
|
||||
if (sendingFromAPIRef.current && msgs.length > 0) {
|
||||
// Reply arrived via WS push (e.g. claude-code SDK). Release all
|
||||
// three guards together — without sendInFlightRef the next
|
||||
// sendMessage() silently no-ops at the synchronous re-entry
|
||||
// check.
|
||||
releaseSendGuards();
|
||||
}
|
||||
}, [pendingAgentMsgs, workspaceId]);
|
||||
|
||||
// Resolve workspace ID → name for activity display
|
||||
const resolveWorkspaceName = useCallback((id: string) => {
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
||||
}, []);
|
||||
|
||||
// Elapsed timer while sending
|
||||
useEffect(() => {
|
||||
@@ -224,43 +609,211 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]);
|
||||
}, [sending, data.runtime]);
|
||||
|
||||
// IntersectionObserver on the top sentinel. Fires loadOlder() the
|
||||
// moment the user scrolls within 200px of the top. AbortController
|
||||
// unwires cleanly on workspace switch / unmount; root is the
|
||||
// scrollable container so we observe only what's visible inside it.
|
||||
const hasMessages = history.messages.length > 0;
|
||||
useEffect(() => {
|
||||
const top = topRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!top || !container) return;
|
||||
if (!history.hasMore) return;
|
||||
const ac = new AbortController();
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (ac.signal.aborted) return;
|
||||
if (entries[0]?.isIntersecting) history.loadOlder();
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
|
||||
);
|
||||
io.observe(top);
|
||||
ac.signal.addEventListener("abort", () => io.disconnect());
|
||||
return () => ac.abort();
|
||||
}, [history.loadOlder, history.hasMore, hasMessages]);
|
||||
// Subscribe to global WS via the singleton ReconnectingSocket (no
|
||||
// per-component WebSocket — the previous pattern dropped events
|
||||
// silently on any reconnect because each panel's raw socket had no
|
||||
// onclose handler).
|
||||
useSocketEvent((msg) => {
|
||||
if (!sending) return;
|
||||
try {
|
||||
if (msg.event === "ACTIVITY_LOGGED") {
|
||||
// Filter to events for THIS workspace. The platform's
|
||||
// BroadcastOnly fires to every connected client, and
|
||||
// without this guard a sibling workspace's a2a_send would
|
||||
// surface as "→ Delegating to X..." inside the wrong
|
||||
// chat panel. (workspace_id on the WS envelope is the
|
||||
// workspace whose activity_log row we just wrote.)
|
||||
if (msg.workspace_id !== workspaceId) return;
|
||||
|
||||
const handleSend = async () => {
|
||||
const p = msg.payload || {};
|
||||
const type = p.activity_type as string;
|
||||
const method = (p.method as string) || "";
|
||||
const status = (p.status as string) || "";
|
||||
const targetId = (p.target_id as string) || "";
|
||||
const durationMs = p.duration_ms as number | undefined;
|
||||
const summary = (p.summary as string) || "";
|
||||
|
||||
let line = "";
|
||||
if (type === "a2a_receive" && method === "message/send") {
|
||||
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
|
||||
if (status === "ok" && durationMs) {
|
||||
const sec = Math.round(durationMs / 1000);
|
||||
line = `← ${targetName} responded (${sec}s)`;
|
||||
// The platform logs a successful a2a_receive once the workspace
|
||||
// has fully produced its reply. That's the authoritative "done"
|
||||
// signal for the spinner — clear it even if the reply hasn't
|
||||
// surfaced through the store yet (it may be delivered shortly
|
||||
// via pendingAgentMsgs or the HTTP .then()).
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own && sendingFromAPIRef.current) {
|
||||
releaseSendGuards();
|
||||
}
|
||||
} else if (status === "error") {
|
||||
line = `⚠ ${targetName} error`;
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own && sendingFromAPIRef.current) {
|
||||
releaseSendGuards();
|
||||
setError("Agent error (Exception) — see workspace logs for details.");
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
const targetName = resolveWorkspaceName(targetId);
|
||||
line = `→ Delegating to ${targetName}...`;
|
||||
} else if (type === "task_update") {
|
||||
if (summary) line = `⟳ ${summary}`;
|
||||
} else if (type === "agent_log") {
|
||||
// Per-tool-use telemetry from claude_sdk_executor's
|
||||
// _report_tool_use. The summary already carries an icon
|
||||
// + human-readable args (📄 Read /path, ⚡ Bash: …)
|
||||
// so we render it verbatim. No icon prefix here — the
|
||||
// emoji at the start of summary is the visual marker.
|
||||
if (summary) line = summary;
|
||||
}
|
||||
|
||||
if (line) {
|
||||
setActivityLog((prev) => appendActivityLine(prev, line));
|
||||
}
|
||||
} else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) {
|
||||
const task = (msg.payload?.current_task as string) || "";
|
||||
if (task) {
|
||||
setActivityLog((prev) => appendActivityLine(prev, `⟳ ${task}`));
|
||||
}
|
||||
}
|
||||
// A2A_RESPONSE is already consumed by the store and its text is
|
||||
// appended to messages via the pendingAgentMsgs effect above; we
|
||||
// don't need to duplicate it here.
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim();
|
||||
const files = pendingFiles;
|
||||
if ((!text && files.length === 0) || !agentReachable || sending || uploading) return;
|
||||
const filesToSend = pendingFiles;
|
||||
// Allow sending if EITHER text OR attachments are present — a user
|
||||
// can drop a file with no text and the agent still receives it.
|
||||
if ((!text && filesToSend.length === 0) || !agentReachable || sending || uploading) return;
|
||||
// Synchronous re-entry guard — see sendInFlightRef comment.
|
||||
if (sendInFlightRef.current) return;
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
// Upload attachments first so we can include URIs in the A2A
|
||||
// message parts. Sequential-before-send: a message with references
|
||||
// to files not yet staged would fail agent-side; staging happens
|
||||
// synchronously via /chat/uploads before message/send dispatch.
|
||||
let uploaded: ChatAttachment[] = [];
|
||||
if (filesToSend.length > 0) {
|
||||
setUploading(true);
|
||||
try {
|
||||
uploaded = await uploadChatFiles(workspaceId, filesToSend);
|
||||
} catch (e) {
|
||||
setUploading(false);
|
||||
sendInFlightRef.current = false;
|
||||
setError(e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed");
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
setInput("");
|
||||
setPendingFiles([]);
|
||||
clearSendError();
|
||||
setMessages((prev) => [...prev, createMessage("user", text, uploaded)]);
|
||||
setSending(true);
|
||||
sendingFromAPIRef.current = true;
|
||||
setError(null);
|
||||
await sendMessage(text, files);
|
||||
// Capture this send's token so the .then()/.catch() callbacks can
|
||||
// detect a newer send that may have superseded them. See the
|
||||
// sendTokenRef declaration for the race scenario this closes.
|
||||
const myToken = ++sendTokenRef.current;
|
||||
|
||||
// Build conversation history from prior messages (last 20)
|
||||
const history = messages
|
||||
.filter((m) => m.role === "user" || m.role === "agent")
|
||||
.slice(-20)
|
||||
.map((m) => ({
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
parts: [{ kind: "text", text: m.content }],
|
||||
}));
|
||||
|
||||
// A2A parts: text part (if any) + file parts (per attachment). The
|
||||
// agent sees both in a single turn, matching the A2A spec shape.
|
||||
// Wire shape is v0 — see A2APart definition above.
|
||||
const parts: A2APart[] = [];
|
||||
if (text) parts.push({ kind: "text", text });
|
||||
for (const att of uploaded) {
|
||||
parts.push({
|
||||
kind: "file",
|
||||
file: {
|
||||
name: att.name,
|
||||
mimeType: att.mimeType,
|
||||
uri: att.uri,
|
||||
size: att.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// A2A calls can legitimately take minutes — LLM latency +
|
||||
// multi-turn tool use is common on slower providers (Hermes+minimax,
|
||||
// Claude Code invoking bash/file tools, etc.). The 15s default
|
||||
// would silently abort the fetch here, leaving the server to
|
||||
// complete the reply and the user staring at
|
||||
// "agent may be unreachable". Match the upload timeout (60s × 2)
|
||||
// for the happy-path ceiling; anything longer is genuinely stuck.
|
||||
api.post<A2AResponse>(`/workspaces/${workspaceId}/a2a`, {
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
messageId: crypto.randomUUID(),
|
||||
parts,
|
||||
},
|
||||
metadata: { history },
|
||||
},
|
||||
}, { timeoutMs: 120_000 })
|
||||
.then((resp) => {
|
||||
// Bail without touching any flags if a newer sendMessage has
|
||||
// already run — its myToken bumped sendTokenRef, so this is
|
||||
// a stale callback for an earlier message. The newer send
|
||||
// owns the in-flight guards now.
|
||||
if (sendTokenRef.current !== myToken) return;
|
||||
// Skip if the WS A2A_RESPONSE event already handled this response.
|
||||
// Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears
|
||||
// it first wins, the other becomes a no-op (no duplicate messages).
|
||||
if (!sendingFromAPIRef.current) {
|
||||
sendInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
const replyText = extractReplyText(resp);
|
||||
const replyFiles = extractFilesFromTask((resp?.result ?? {}) as Record<string, unknown>);
|
||||
if (replyText || replyFiles.length > 0) {
|
||||
setMessages((prev) =>
|
||||
appendMessageDeduped(prev, createMessage("agent", replyText, replyFiles)),
|
||||
);
|
||||
}
|
||||
releaseSendGuards();
|
||||
})
|
||||
.catch(() => {
|
||||
// Stale-callback guard — same rationale as .then().
|
||||
if (sendTokenRef.current !== myToken) return;
|
||||
// Same dedup guard as .then(): if a WS path (pendingAgentMsgs
|
||||
// or ACTIVITY_LOGGED a2a_receive ok) already delivered the
|
||||
// reply, sendingFromAPIRef is already false and there's
|
||||
// nothing to roll back. Surfacing "Failed to send" here would
|
||||
// contradict the agent reply the user is currently reading —
|
||||
// exactly the false-positive observed when the HTTP request
|
||||
// hung up (proxy idle / 502) after WS already won.
|
||||
if (!sendingFromAPIRef.current) {
|
||||
sendInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
releaseSendGuards();
|
||||
setError("Failed to send message — agent may be unreachable");
|
||||
});
|
||||
};
|
||||
|
||||
const onFilesPicked = (fileList: FileList | null) => {
|
||||
if (!fileList) return;
|
||||
const picked = Array.from(fileList);
|
||||
// Deduplicate against current pending set by name+size — user
|
||||
// picking the same file twice shouldn't append it.
|
||||
setPendingFiles((prev) => {
|
||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
|
||||
@@ -271,7 +824,35 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const removePendingFile = (index: number) =>
|
||||
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
// Monotonic counter so two paste events within the same wall-clock
|
||||
// second still produce distinct filenames. Without this, on
|
||||
// Firefox (where pasted images have an empty `file.name`), two
|
||||
// pastes ~100ms apart could yield identical synthetic names AND
|
||||
// identical sizes, collapsing into one attachment via the
|
||||
// `name:size` dedup in onFilesPicked.
|
||||
const pasteCounterRef = useRef(0);
|
||||
|
||||
/** Paste-from-clipboard image attachment.
|
||||
*
|
||||
* Browser clipboard image items arrive as `File`s whose `name` is
|
||||
* often a generic "image.png" (Chrome) or empty (Firefox/Safari),
|
||||
* so two consecutive screenshot pastes collide on the name+size
|
||||
* dedup the file-picker uses. Re-tag each pasted image with a
|
||||
* per-paste unique name so dedup keeps them apart and the upload
|
||||
* pipeline (which expects a non-empty filename) is happy.
|
||||
*
|
||||
* Falls through to onFilesPicked via direct File[] (NOT through
|
||||
* the DataTransfer constructor — that throws on Safari < 14.1
|
||||
* and old Edge, silently aborting the paste).
|
||||
*
|
||||
* Only intercepts the paste when the clipboard has at least one
|
||||
* image; text-only pastes fall through to the textarea's default
|
||||
* behaviour. */
|
||||
const mimeToExt = (mime: string): string => {
|
||||
// Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`,
|
||||
// `"jpeg"`, `"webp"` etc. which produce ugly filenames and may
|
||||
// trip server-side extension allowlists. Map known types
|
||||
// explicitly; unknown falls back to a safe default.
|
||||
if (mime === "image/svg+xml") return "svg";
|
||||
if (mime === "image/jpeg") return "jpg";
|
||||
if (mime === "image/png") return "png";
|
||||
@@ -292,16 +873,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
const ext = mimeToExt(file.type);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, 19);
|
||||
const seq = pasteCounterRef.current++;
|
||||
const fname = `pasted-${stamp}-${seq}-${i}.${ext}`;
|
||||
imageFiles.push(new File([file], fname, { type: file.type }));
|
||||
}
|
||||
if (imageFiles.length === 0) return;
|
||||
e.preventDefault();
|
||||
// Reuse the picker path so file-size guards, dedup, and pending-
|
||||
// list state all run through the same code. Build a synthetic
|
||||
// FileList-like object to avoid the DataTransfer constructor —
|
||||
// that's missing on Safari < 14.1 / old Edge and would silently
|
||||
// throw, leaving the paste a no-op.
|
||||
addPastedFiles(imageFiles);
|
||||
};
|
||||
|
||||
// Variant of onFilesPicked that accepts a File[] directly, sidestepping
|
||||
// the DataTransfer-FileList round-trip. Same dedup + state shape.
|
||||
const addPastedFiles = (files: File[]) => {
|
||||
setPendingFiles((prev) => {
|
||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
@@ -309,6 +900,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
// Drag-and-drop staging. dragDepthRef counts enter vs leave events so
|
||||
// the overlay doesn't flicker when the cursor crosses nested children
|
||||
// (textarea, buttons) — dragenter/dragleave fire for every boundary.
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const dropEnabled = agentReachable && !sending && !uploading;
|
||||
const isFileDrag = (e: React.DragEvent) =>
|
||||
Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
@@ -338,6 +934,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
};
|
||||
|
||||
const downloadAttachment = (att: ChatAttachment) => {
|
||||
// Errors here are rare but user-visible (401 on a revoked token,
|
||||
// 404 if the agent deleted the file). Surface via the inline
|
||||
// error banner — the message list itself stays untouched.
|
||||
downloadChatFile(workspaceId, att).catch((e) => {
|
||||
setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed");
|
||||
});
|
||||
@@ -391,26 +990,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
)}
|
||||
{/* Messages */}
|
||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{history.loading && (
|
||||
{loading && (
|
||||
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
|
||||
)}
|
||||
{!history.loading && history.loadError !== null && history.messages.length === 0 && (
|
||||
{!loading && loadError !== null && messages.length === 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
|
||||
>
|
||||
<p className="text-[11px] text-bad mb-1.5">
|
||||
Failed to load chat history: {history.loadError}
|
||||
Failed to load chat history: {loadError}
|
||||
</p>
|
||||
<button
|
||||
onClick={history.loadInitial}
|
||||
onClick={loadInitial}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!history.loading && history.loadError === null && history.messages.length === 0 && (
|
||||
{!loading && loadError === null && messages.length === 0 && (
|
||||
<div className="text-xs text-ink-mid text-center py-8">
|
||||
No messages yet. Send a message to start chatting with this agent.
|
||||
</div>
|
||||
@@ -428,12 +1027,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
instead of showing a "no more messages" footer — the user's
|
||||
scroll resting against the top of the conversation IS the
|
||||
signal. */}
|
||||
{history.hasMore && history.messages.length > 0 && (
|
||||
{hasMore && messages.length > 0 && (
|
||||
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
|
||||
{history.loadingOlder ? "Loading older messages…" : " "}
|
||||
{loadingOlder ? "Loading older messages…" : " "}
|
||||
</div>
|
||||
)}
|
||||
{history.messages.map((msg) => (
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
@@ -593,10 +1192,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{displayError && (
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-red-300">{displayError}</span>
|
||||
<span className="text-[10px] text-red-300">{error}</span>
|
||||
{!isOnline && (
|
||||
<button
|
||||
onClick={() => setConfirmRestart(true)}
|
||||
@@ -664,7 +1263,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
e.keyCode !== 229
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
onPaste={onPasteIntoComposer}
|
||||
@@ -674,7 +1273,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
onClick={sendMessage}
|
||||
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
|
||||
@@ -45,54 +45,11 @@ export function FilesTab({ workspaceId, data }: Props) {
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} runtime={data?.runtime} />;
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
/** Picks the initial root for the FilesTab dropdown based on the
|
||||
* workspace's runtime. Decision: per-runtime default (Hongming
|
||||
* 2026-05-15, internal#425 Decisions §2).
|
||||
*
|
||||
* - openclaw → `/agent-home` (the agent's identity/state — the
|
||||
* user-facing interesting files for that runtime live in
|
||||
* `~/.openclaw/` inside the container, which `/agent-home` maps to
|
||||
* via the Phase 2b docker-exec backend).
|
||||
* - everything else (claude-code, hermes, external-like, undefined)
|
||||
* → `/configs` (the legacy default — managed config that flows
|
||||
* through the per-runtime indirection in
|
||||
* workspace-server/internal/handlers/template_files_eic.go).
|
||||
*
|
||||
* When the runtime is undefined (legacy callers that don't thread
|
||||
* `data` through, or a workspace whose runtime field hasn't loaded
|
||||
* yet) the default is `/configs` — matches today's behaviour, no
|
||||
* surprise.
|
||||
*
|
||||
* Note on `/agent-home` pre-Phase-2b: the backend short-circuits
|
||||
* with HTTP 501 and the canonical "implementation pending" body.
|
||||
* The tab renders empty + the error banner explains. This is by
|
||||
* design — lets us land the canvas UX before the backend ships,
|
||||
* per the RFC's phased rollout. The 501 is graceful: it doesn't
|
||||
* poison error toasts or generate "workspace not found" noise.
|
||||
*
|
||||
* Adding a new runtime that should default to `/agent-home`: add it
|
||||
* to the agentHomeDefaultRuntimes set below. Adding a runtime that
|
||||
* should default to a different root: extend this function. */
|
||||
const agentHomeDefaultRuntimes = new Set(["openclaw"]);
|
||||
|
||||
function defaultRootForRuntime(runtime: string | undefined): string {
|
||||
if (runtime && agentHomeDefaultRuntimes.has(runtime)) {
|
||||
return "/agent-home";
|
||||
}
|
||||
return "/configs";
|
||||
}
|
||||
|
||||
function PlatformOwnedFilesTab({
|
||||
workspaceId,
|
||||
runtime,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
runtime?: string;
|
||||
}) {
|
||||
const [root, setRoot] = useState(() => defaultRootForRuntime(runtime));
|
||||
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const [root, setRoot] = useState("/configs");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [editContent, setEditContent] = useState("");
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
import { useRef } from "react";
|
||||
import { getIcon } from "./tree";
|
||||
|
||||
// secretShapeMarker is the canonical body the workspace-server Files
|
||||
// API returns when a file's path OR content matched a credential
|
||||
// regex (internal#425 RFC, Phase 2b — backed by
|
||||
// workspace-server/internal/secrets.ScanBytes). The marker is a
|
||||
// fixed prefix so the canvas can detect it without parsing JSON and
|
||||
// without round-tripping the matched bytes through the editor (which
|
||||
// would defeat the purpose — clipboard, browser history, log
|
||||
// surfaces would all see them).
|
||||
//
|
||||
// Today (Phase 1 / before 2b ships) the backend returns 501 for the
|
||||
// only root that uses this path, so the marker is dead code until
|
||||
// 2b lands. Wiring it in now keeps the canvas + backend contracts
|
||||
// aligned in one PR rather than a follow-up. The constant is
|
||||
// importable so a future test can pin the exact string.
|
||||
export const SECRET_SHAPE_DENIED_MARKER = "<denied: secret-shape>";
|
||||
|
||||
interface Props {
|
||||
selectedFile: string | null;
|
||||
fileContent: string;
|
||||
@@ -47,22 +31,6 @@ export function FileEditor({
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isDirty = editContent !== fileContent;
|
||||
|
||||
// internal#425 Phase 3: detect the secret-shape denial marker and
|
||||
// render a placeholder instead of the editor. The marker comes
|
||||
// from workspace-server Phase 2b (secrets.ScanBytes) which refuses
|
||||
// to surface the file's bytes. We deliberately don't expose
|
||||
// the matched pattern's Name here — the canvas just shows the
|
||||
// generic denial. The Files API log surface has the Pattern.Name
|
||||
// for operators who need to debug a false positive.
|
||||
const isSecretShapeDenied = fileContent === SECRET_SHAPE_DENIED_MARKER;
|
||||
|
||||
// /agent-home is read-only from the canvas (Phase 2b ships read +
|
||||
// delete; Phase-2b-followup may add write). Edits to /configs are
|
||||
// unchanged. Until 2b ships, /agent-home returns 501 so this
|
||||
// read-only gate is also dead code, but wiring it in now keeps
|
||||
// the UI honest the moment 2b lands without a follow-up canvas PR.
|
||||
const isReadOnlyRoot = root !== "/configs";
|
||||
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@@ -107,42 +75,11 @@ export function FileEditor({
|
||||
{/* Editor area */}
|
||||
{loadingFile ? (
|
||||
<div className="p-4 text-xs text-ink-mid">Loading...</div>
|
||||
) : isSecretShapeDenied ? (
|
||||
// Files API refused to surface this file's bytes because its
|
||||
// path or content matched a credential regex
|
||||
// (workspace-server/internal/secrets, internal#425 Phase 2b).
|
||||
// We render a placeholder INSTEAD OF the textarea so the
|
||||
// matched bytes never enter the DOM. Clipboard / view-source
|
||||
// / element-inspector all see the placeholder, not the
|
||||
// credential.
|
||||
<div
|
||||
role="region"
|
||||
aria-label="File content denied"
|
||||
className="flex-1 flex items-center justify-center p-6 bg-surface"
|
||||
>
|
||||
<div className="max-w-md text-center space-y-2">
|
||||
<div className="text-2xl opacity-40">🛡️</div>
|
||||
<p className="text-[11px] font-mono text-warm">
|
||||
{SECRET_SHAPE_DENIED_MARKER}
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
||||
The platform refused to surface this file because its
|
||||
path or content matched a credential-shape pattern.
|
||||
The bytes never left the workspace container.
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
||||
If this is a false positive (test fixture, docs example,
|
||||
or content that happens to share a credential's shape),
|
||||
rename the file or adjust the content via the workspace
|
||||
terminal so the regex no longer matches, then refresh.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={editContent}
|
||||
readOnly={isReadOnlyRoot}
|
||||
readOnly={root !== "/configs"}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
|
||||
@@ -38,15 +38,6 @@ export function FilesToolbar({
|
||||
<option value="/home">/home</option>
|
||||
<option value="/workspace">/workspace</option>
|
||||
<option value="/plugins">/plugins</option>
|
||||
{/* internal#425 Phase 1+3: container-internal $HOME root.
|
||||
Backend lands the docker-exec dispatch in Phase 2b. Until
|
||||
then the stub returns 501 with a canonical
|
||||
"implementation pending" message — the dropdown renders
|
||||
the option so the canvas affordance is design-frozen
|
||||
even before the backend ships.
|
||||
Runtime-default selection logic in FilesTab.tsx picks
|
||||
this as the initial value for openclaw workspaces. */}
|
||||
<option value="/agent-home">/agent-home</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
|
||||
</div>
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for the /agent-home root selector + per-runtime default-root
|
||||
* + secret-shape denial placeholder (internal#425 Phase 3).
|
||||
*
|
||||
* Separate file so the diff is reviewable as a unit and the existing
|
||||
* FilesToolbar / FileEditor / FilesTab tests don't have to grow
|
||||
* agent-home-specific cases. Once Phase 2b lands, the read-only +
|
||||
* 501-stub assertions here can be tightened (or moved into the main
|
||||
* test file as the agent-home root becomes a first-class affordance).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import {
|
||||
FileEditor,
|
||||
SECRET_SHAPE_DENIED_MARKER,
|
||||
} from "../FileEditor";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("internal#425 Phase 3 — /agent-home root selector", () => {
|
||||
it("dropdown includes /agent-home as an option", () => {
|
||||
// Pins the affordance is in the DOM even pre-Phase-2b — the
|
||||
// canvas design freezes today, the backend lands the dispatch
|
||||
// later. Without this, a future refactor that drops the option
|
||||
// would silently regress the RFC's Phase 1 contract (canvas
|
||||
// visibility) without breaking any other test.
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", {
|
||||
name: /file root directory/i,
|
||||
}) as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values).toContain("/agent-home");
|
||||
});
|
||||
|
||||
it("dropdown shows /agent-home as the SELECTED root when prop is /agent-home", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/agent-home"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", {
|
||||
name: /file root directory/i,
|
||||
}) as HTMLSelectElement;
|
||||
expect(select.value).toBe("/agent-home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal#425 Phase 3 — secret-shape denial placeholder", () => {
|
||||
// Files API Phase 2b returns SECRET_SHAPE_DENIED_MARKER as the file
|
||||
// body when the file's path or content matched a credential regex.
|
||||
// The editor MUST render the marker as a placeholder, not pump it
|
||||
// through the textarea — that would put the marker (and any future
|
||||
// matched bytes if the backend contract changes) into the DOM
|
||||
// value, clipboard, and inspector.
|
||||
|
||||
it("renders the denial placeholder INSTEAD of the textarea when fileContent is the marker", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="agent/.openclaw/secrets.env"
|
||||
fileContent={SECRET_SHAPE_DENIED_MARKER}
|
||||
editContent={SECRET_SHAPE_DENIED_MARKER}
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/agent-home"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Placeholder region present
|
||||
expect(
|
||||
screen.getByRole("region", { name: /file content denied/i }),
|
||||
).toBeTruthy();
|
||||
// Marker text visible (so a debugging operator sees the canonical
|
||||
// contract string without having to dig into the source).
|
||||
expect(screen.getByText(SECRET_SHAPE_DENIED_MARKER)).toBeTruthy();
|
||||
// Critically: NO textarea — the bytes never reach a controlled
|
||||
// input. A regression that re-introduces the textarea path would
|
||||
// make the matched marker (and any future content) selectable +
|
||||
// copyable.
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the textarea normally when fileContent is regular content", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="config.yaml"
|
||||
fileContent="name: openclaw\n"
|
||||
editContent="name: openclaw\n"
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/configs"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: /file content denied/i }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("/agent-home renders textarea READ-ONLY for non-denied content", () => {
|
||||
// Phase 2b ships read + delete on /agent-home; write semantics
|
||||
// are decided later. Until then, the canvas presents the editor
|
||||
// as read-only so a user can't type into a buffer that the
|
||||
// backend will refuse to PUT. Without this gate, the user would
|
||||
// edit, hit Save, get a 501, and lose their context for why.
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile=".openclaw/agent-card.json"
|
||||
fileContent='{"name":"openclaw"}'
|
||||
editContent='{"name":"openclaw"}'
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/agent-home"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("/configs renders textarea WRITABLE (regression guard for the read-only gate)", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="config.yaml"
|
||||
fileContent="name: x\n"
|
||||
editContent="name: x\n"
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/configs"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal#425 Phase 3 — marker constant is the canonical string", () => {
|
||||
// The marker string is part of the canvas <-> workspace-server
|
||||
// contract. The workspace-server emits this exact body; the canvas
|
||||
// detects it by exact-equality. A typo on either side would
|
||||
// silently break detection — the canvas would render the literal
|
||||
// string in the textarea instead of the placeholder. Pin the
|
||||
// contract value here.
|
||||
it("matches the contract value '<denied: secret-shape>'", () => {
|
||||
expect(SECRET_SHAPE_DENIED_MARKER).toBe("<denied: secret-shape>");
|
||||
});
|
||||
});
|
||||
@@ -194,7 +194,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetForm(); setShowForm(true); }}
|
||||
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
|
||||
>
|
||||
+ Add Schedule
|
||||
</button>
|
||||
@@ -339,7 +339,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
? "Last run OK — click to disable"
|
||||
: "Never run — click to enable"
|
||||
}
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900 ${
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
sched.last_status === "error"
|
||||
? "bg-red-400"
|
||||
: sched.last_status === "ok"
|
||||
@@ -376,7 +376,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => handleRunNow(sched)}
|
||||
aria-label={`Run schedule ${sched.name} now`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
|
||||
title="Run now"
|
||||
>
|
||||
▶
|
||||
@@ -384,7 +384,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => handleEdit(sched)}
|
||||
aria-label={`Edit schedule ${sched.name}`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
@@ -392,7 +392,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
|
||||
aria-label={`Delete schedule ${sched.name}`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { useChatHistory } from "./useChatHistory";
|
||||
export { useChatSend } from "./useChatSend";
|
||||
export { useChatSocket } from "./useChatSocket";
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
/** Resolve a workspace ID to its human-readable name.
|
||||
* Falls back to the first 8 chars of the ID. */
|
||||
export function resolveWorkspaceName(id: string): string {
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
|
||||
|
||||
const INITIAL_HISTORY_LIMIT = 10;
|
||||
const OLDER_HISTORY_BATCH = 20;
|
||||
|
||||
async function loadMessagesFromDB(
|
||||
workspaceId: string,
|
||||
limit: number,
|
||||
beforeTs?: string,
|
||||
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (beforeTs) params.set("before_ts", beforeTs);
|
||||
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
|
||||
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
|
||||
);
|
||||
return {
|
||||
messages: resp.messages ?? [],
|
||||
error: null,
|
||||
reachedEnd: resp.reached_end,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
messages: [],
|
||||
error: err instanceof Error ? err.message : "Failed to load chat history",
|
||||
reachedEnd: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScrollAnchor {
|
||||
savedDistanceFromBottom: number;
|
||||
expectFirstIdNotEqual: string | null;
|
||||
}
|
||||
|
||||
export function useChatHistory(
|
||||
workspaceId: string,
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const fetchTokenRef = useRef(0);
|
||||
const oldestMessageRef = useRef<ChatMessage | null>(null);
|
||||
const hasMoreRef = useRef(true);
|
||||
const inflightRef = useRef(false);
|
||||
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
oldestMessageRef.current = messages[0] ?? null;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
hasMoreRef.current = hasMore;
|
||||
}, [hasMore]);
|
||||
|
||||
const loadInitial = useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setHasMore(true);
|
||||
fetchTokenRef.current += 1;
|
||||
const myToken = fetchTokenRef.current;
|
||||
return loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
|
||||
({ messages: msgs, error: fetchErr, reachedEnd }) => {
|
||||
if (fetchTokenRef.current !== myToken) return;
|
||||
setMessages(msgs);
|
||||
setLoadError(fetchErr);
|
||||
setHasMore(!reachedEnd);
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (inflightRef.current || !hasMoreRef.current) return;
|
||||
const oldest = oldestMessageRef.current;
|
||||
if (!oldest) return;
|
||||
const container = containerRef?.current;
|
||||
if (!container) return;
|
||||
inflightRef.current = true;
|
||||
scrollAnchorRef.current = {
|
||||
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
|
||||
expectFirstIdNotEqual: oldest.id,
|
||||
};
|
||||
fetchTokenRef.current += 1;
|
||||
const myToken = fetchTokenRef.current;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const { messages: older, reachedEnd } = await loadMessagesFromDB(
|
||||
workspaceId,
|
||||
OLDER_HISTORY_BATCH,
|
||||
oldest.timestamp,
|
||||
);
|
||||
if (fetchTokenRef.current !== myToken) {
|
||||
scrollAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (older.length > 0) {
|
||||
setMessages((prev) => [...older, ...prev]);
|
||||
} else {
|
||||
scrollAnchorRef.current = null;
|
||||
}
|
||||
setHasMore(!reachedEnd);
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
inflightRef.current = false;
|
||||
}
|
||||
}, [workspaceId, containerRef]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
loadError,
|
||||
loadingOlder,
|
||||
hasMore,
|
||||
loadInitial,
|
||||
loadOlder,
|
||||
appendMessageDeduped: (msg: ChatMessage) =>
|
||||
setMessages((prev) => appendMessageDedupedFn(prev, msg)),
|
||||
setMessages,
|
||||
scrollAnchorRef,
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { uploadChatFiles } from "../uploads";
|
||||
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
|
||||
import { extractFilesFromTask } from "../message-parser";
|
||||
|
||||
interface A2APart {
|
||||
kind: string;
|
||||
text?: string;
|
||||
file?: {
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
size?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface A2AResponse {
|
||||
result?: {
|
||||
parts?: A2APart[];
|
||||
artifacts?: Array<{ parts: A2APart[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
return parts
|
||||
.filter((p) => p.kind === "text")
|
||||
.map((p) => p.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
};
|
||||
const result = resp?.result;
|
||||
const collected: string[] = [];
|
||||
const fromParts = collect(result?.parts);
|
||||
if (fromParts) collected.push(fromParts);
|
||||
if (result?.artifacts) {
|
||||
for (const a of result.artifacts) {
|
||||
const t = collect(a.parts);
|
||||
if (t) collected.push(t);
|
||||
}
|
||||
}
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
export interface UseChatSendOptions {
|
||||
getHistoryMessages: () => ChatMessage[];
|
||||
onUserMessage?: (msg: ChatMessage) => void;
|
||||
onAgentMessage?: (msg: ChatMessage) => void;
|
||||
}
|
||||
|
||||
export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const sendInFlightRef = useRef(false);
|
||||
const sendingFromAPIRef = useRef(false);
|
||||
const sendTokenRef = useRef(0);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const releaseSendGuards = useCallback(() => {
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string, files: File[] = []) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && files.length === 0) || sending || uploading) return;
|
||||
if (sendInFlightRef.current) return;
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
let uploaded: ChatAttachment[] = [];
|
||||
if (files.length > 0) {
|
||||
setUploading(true);
|
||||
try {
|
||||
uploaded = await uploadChatFiles(workspaceId, files);
|
||||
} catch (e) {
|
||||
setUploading(false);
|
||||
sendInFlightRef.current = false;
|
||||
setError(
|
||||
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
const userMsg = createMessage("user", trimmed, uploaded);
|
||||
optionsRef.current.onUserMessage?.(userMsg);
|
||||
|
||||
setSending(true);
|
||||
sendingFromAPIRef.current = true;
|
||||
setError(null);
|
||||
const myToken = ++sendTokenRef.current;
|
||||
|
||||
const history = optionsRef.current
|
||||
.getHistoryMessages()
|
||||
.filter((m) => m.role === "user" || m.role === "agent")
|
||||
.slice(-20)
|
||||
.map((m) => ({
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
parts: [{ kind: "text", text: m.content }],
|
||||
}));
|
||||
|
||||
const parts: A2APart[] = [];
|
||||
if (trimmed) parts.push({ kind: "text", text: trimmed });
|
||||
for (const att of uploaded) {
|
||||
parts.push({
|
||||
kind: "file",
|
||||
file: {
|
||||
name: att.name,
|
||||
mimeType: att.mimeType,
|
||||
uri: att.uri,
|
||||
size: att.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
api
|
||||
.post<A2AResponse>(
|
||||
`/workspaces/${workspaceId}/a2a`,
|
||||
{
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
messageId: crypto.randomUUID(),
|
||||
parts,
|
||||
},
|
||||
metadata: { history },
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 120_000 },
|
||||
)
|
||||
.then((resp) => {
|
||||
if (sendTokenRef.current !== myToken) return;
|
||||
if (!sendingFromAPIRef.current) {
|
||||
sendInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
const replyText = extractReplyText(resp);
|
||||
const replyFiles = extractFilesFromTask(
|
||||
(resp?.result ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
if (replyText || replyFiles.length > 0) {
|
||||
optionsRef.current.onAgentMessage?.(
|
||||
createMessage("agent", replyText, replyFiles),
|
||||
);
|
||||
}
|
||||
releaseSendGuards();
|
||||
})
|
||||
.catch(() => {
|
||||
if (sendTokenRef.current !== myToken) return;
|
||||
if (!sendingFromAPIRef.current) {
|
||||
sendInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
releaseSendGuards();
|
||||
setError("Failed to send message — agent may be unreachable");
|
||||
});
|
||||
},
|
||||
[workspaceId, sending, uploading],
|
||||
);
|
||||
|
||||
return {
|
||||
sending,
|
||||
uploading,
|
||||
sendMessage,
|
||||
error,
|
||||
clearError,
|
||||
releaseSendGuards,
|
||||
sendingFromAPIRef,
|
||||
};
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { createMessage, type ChatMessage } from "../types";
|
||||
|
||||
export interface UseChatSocketCallbacks {
|
||||
onAgentMessage?: (msg: ChatMessage) => void;
|
||||
onActivityLog?: (entry: string) => void;
|
||||
onSendComplete?: () => void;
|
||||
onSendError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useChatSocket(
|
||||
workspaceId: string,
|
||||
callbacks: UseChatSocketCallbacks,
|
||||
): void {
|
||||
const callbacksRef = useRef(callbacks);
|
||||
callbacksRef.current = callbacks;
|
||||
|
||||
// Agent push messages from global store
|
||||
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
|
||||
useEffect(() => {
|
||||
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(workspaceId);
|
||||
for (const m of msgs) {
|
||||
callbacksRef.current.onAgentMessage?.(
|
||||
createMessage("agent", m.content, m.attachments),
|
||||
);
|
||||
}
|
||||
if (msgs.length > 0) {
|
||||
callbacksRef.current.onSendComplete?.();
|
||||
}
|
||||
}, [pendingAgentMsgs, workspaceId]);
|
||||
|
||||
const resolveWorkspaceName = useCallback((id: string) => {
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
||||
}, []);
|
||||
|
||||
useSocketEvent((msg) => {
|
||||
try {
|
||||
if (msg.event === "ACTIVITY_LOGGED") {
|
||||
if (msg.workspace_id !== workspaceId) return;
|
||||
|
||||
const p = msg.payload || {};
|
||||
const type = p.activity_type as string;
|
||||
const method = (p.method as string) || "";
|
||||
const status = (p.status as string) || "";
|
||||
const targetId = (p.target_id as string) || "";
|
||||
const durationMs = p.duration_ms as number | undefined;
|
||||
const summary = (p.summary as string) || "";
|
||||
|
||||
let line = "";
|
||||
if (type === "a2a_receive" && method === "message/send") {
|
||||
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
|
||||
if (status === "ok" && durationMs) {
|
||||
const sec = Math.round(durationMs / 1000);
|
||||
line = `← ${targetName} responded (${sec}s)`;
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own) callbacksRef.current.onSendComplete?.();
|
||||
} else if (status === "error") {
|
||||
line = `⚠ ${targetName} error`;
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own) {
|
||||
callbacksRef.current.onSendComplete?.();
|
||||
callbacksRef.current.onSendError?.(
|
||||
"Agent error (Exception) — see workspace logs for details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
const targetName = resolveWorkspaceName(targetId);
|
||||
line = `→ Delegating to ${targetName}...`;
|
||||
} else if (type === "task_update") {
|
||||
if (summary) line = `⟳ ${summary}`;
|
||||
} else if (type === "agent_log") {
|
||||
if (summary) line = summary;
|
||||
}
|
||||
|
||||
if (line) {
|
||||
callbacksRef.current.onActivityLog?.(line);
|
||||
}
|
||||
} else if (
|
||||
msg.event === "TASK_UPDATED" &&
|
||||
msg.workspace_id === workspaceId
|
||||
) {
|
||||
const task = (msg.payload?.current_task as string) || "";
|
||||
if (task) {
|
||||
callbacksRef.current.onActivityLog?.(`⟳ ${task}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,2 @@
|
||||
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
|
||||
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
|
||||
export { useChatHistory } from "./hooks/useChatHistory";
|
||||
export { useChatSend } from "./hooks/useChatSend";
|
||||
export { useChatSocket } from "./hooks/useChatSocket";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PreflightResult,
|
||||
type Template,
|
||||
} from "@/lib/deploy-preflight";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
import { MissingKeysModal } from "@/components/MissingKeysModal";
|
||||
|
||||
/**
|
||||
@@ -105,7 +106,7 @@ export function useTemplateDeploy(
|
||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||
name: template.name,
|
||||
template: template.id,
|
||||
tier: template.tier,
|
||||
tier: isSaaSTenant() ? 4 : template.tier,
|
||||
canvas: coords,
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
# Staging E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# Hermes and OpenClaw were repeatedly reported "fleet-verified / cascade-
|
||||
# complete" because the *proxy* signals were green:
|
||||
# - registry-registration + heartbeat (Hermes), and
|
||||
# - model round-trip 200 (OpenClaw).
|
||||
# But a freshly-provisioned workspace, asked on canvas "can you see your
|
||||
# peers", actually FAILS:
|
||||
# - Hermes: 401 on the molecule MCP `list_peers` call,
|
||||
# - OpenClaw: falls back to native `sessions_list`, sees no platform peers.
|
||||
# Tasks #142/#159 were even marked "completed" under this same proxy flaw.
|
||||
#
|
||||
# This script codifies the LITERAL user-facing path so it can never silently
|
||||
# regress: it provisions a brand-new throwaway org + sibling workspaces via
|
||||
# the real control-plane provisioning path, then for each runtime that should
|
||||
# have platform peer-visibility it drives the EXACT MCP call the canvas agent
|
||||
# makes — `POST /workspaces/:id/mcp` JSON-RPC tools/call name=list_peers,
|
||||
# authenticated by that workspace's own bearer token through the real
|
||||
# WorkspaceAuth + MCPRateLimiter middleware chain. It then asserts:
|
||||
# (1) HTTP 200,
|
||||
# (2) JSON-RPC `result` present (NOT an `error` object — a -32000
|
||||
# "tool call failed" or a 401 from WorkspaceAuth fails here),
|
||||
# (3) the returned peer set CONTAINS the other provisioned sibling
|
||||
# workspace IDs — not an empty list, not a native-sessions fallback.
|
||||
#
|
||||
# This is NOT a proxy. It does not look at a registry row, /health, the
|
||||
# heartbeat table, or `GET /registry/:id/peers`. It drives the byte-for-byte
|
||||
# JSON-RPC envelope that mcp_molecule_list_peers issues from a real agent.
|
||||
#
|
||||
# It is written to FAIL on today's broken Hermes/OpenClaw behavior and go
|
||||
# green only when the in-flight root-cause fixes (Hermes-401, OpenClaw MCP
|
||||
# wiring) actually land. That is the point: it is the objective proof gate.
|
||||
#
|
||||
# AUTH MODEL (mirrors tests/e2e/test_staging_full_saas.sh)
|
||||
# --------------------------------------------------------
|
||||
# Single MOLECULE_ADMIN_TOKEN (= CP_ADMIN_API_TOKEN on Railway staging)
|
||||
# drives: POST /cp/admin/orgs (provision), GET
|
||||
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
|
||||
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
|
||||
# tenant workspace creation; each workspace's OWN auth_token (returned by
|
||||
# POST /workspaces) drives its MCP call.
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
# Optional env:
|
||||
# MOLECULE_CP_URL default https://staging-api.moleculesai.app
|
||||
# E2E_RUN_ID slug suffix; CI passes ${GITHUB_RUN_ID}
|
||||
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
|
||||
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
|
||||
# LLM provider key injected so the runtime can boot
|
||||
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 every runtime saw its peers via the literal MCP call
|
||||
# 1 generic failure
|
||||
# 2 missing required env
|
||||
# 3 provisioning timed out
|
||||
# 4 teardown left orphan resources
|
||||
# 10 peer-visibility regression reproduced (the gate firing as designed)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-1800}"
|
||||
|
||||
# Slug MUST start with 'e2e-' so the sweep-stale-e2e-orgs safety net
|
||||
# (EPHEMERAL_PREFIXES) catches any leak this run fails to tear down.
|
||||
SLUG="e2e-pv-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
|
||||
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
|
||||
|
||||
ORG_ID=""
|
||||
TENANT_URL=""
|
||||
TENANT_TOKEN=""
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
admin_call() {
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -sS -X "$method" "$CP_URL$path" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" "$@"
|
||||
}
|
||||
tenant_call() {
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl -sS -X "$method" "$TENANT_URL$path" \
|
||||
-H "Authorization: Bearer $TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Content-Type: application/json" "$@"
|
||||
}
|
||||
|
||||
# ─── Scoped teardown ───────────────────────────────────────────────────
|
||||
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
|
||||
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
|
||||
# sweep — honors feedback_cleanup_after_each_test and
|
||||
# feedback_never_run_cluster_cleanup_tests_on_live_platform. The
|
||||
# workflow's always() step + sweep-stale-e2e-orgs are the outer nets.
|
||||
teardown() {
|
||||
local rc=$?
|
||||
set +e
|
||||
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
|
||||
echo ""
|
||||
log "[teardown] E2E_KEEP_ORG=1 — leaving $SLUG for debugging (REMEMBER TO DELETE)"
|
||||
exit $rc
|
||||
fi
|
||||
echo ""
|
||||
log "[teardown] DELETE /cp/admin/tenants/$SLUG (scoped to this run only)"
|
||||
admin_call DELETE "/cp/admin/tenants/$SLUG" --max-time 120 \
|
||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1
|
||||
for j in $(seq 1 24); do
|
||||
LIST=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null)
|
||||
LEAK=$(echo "$LIST" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(1); sys.exit(0)
|
||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
||||
print(sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('instance_status') not in ('purged',) and o.get('status') != 'purged'))
|
||||
" 2>/dev/null || echo 1)
|
||||
if [ "$LEAK" = "0" ]; then
|
||||
log "[teardown] ✓ $SLUG purged (after ${j}x5s)"
|
||||
exit $rc
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::warning::[teardown] $SLUG still present after 120s — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES" >&2
|
||||
[ $rc -eq 0 ] && rc=4
|
||||
exit $rc
|
||||
}
|
||||
trap teardown EXIT INT TERM
|
||||
|
||||
# ─── 1. Provision the throwaway org ────────────────────────────────────
|
||||
log "1/6 POST /cp/admin/orgs — slug=$SLUG"
|
||||
CREATE=$(admin_call POST /cp/admin/orgs \
|
||||
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E peer-visibility $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
|
||||
ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$ORG_ID" ] || fail "org creation failed: $(echo "$CREATE" | head -c 300)"
|
||||
log " ORG_ID=$ORG_ID"
|
||||
|
||||
# ─── 2. Wait for tenant EC2 + DNS ──────────────────────────────────────
|
||||
log "2/6 waiting for tenant instance_status=running (cold EC2 + cloudflared)..."
|
||||
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
||||
while true; do
|
||||
[ "$(date +%s)" -gt "$DEADLINE" ] && fail "tenant never came up within ${PROVISION_TIMEOUT_SECS}s"
|
||||
STATUS=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: sys.exit(0)
|
||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
||||
for o in orgs:
|
||||
if o.get('slug') == '$SLUG':
|
||||
print(o.get('instance_status') or o.get('status') or 'unknown'); break
|
||||
" 2>/dev/null)
|
||||
case "$STATUS" in running|online|ready) break ;; esac
|
||||
sleep 10
|
||||
done
|
||||
log " tenant status=$STATUS"
|
||||
|
||||
# ─── 3. Per-tenant admin token + tenant URL ────────────────────────────
|
||||
log "3/6 fetching per-tenant admin token..."
|
||||
TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token")
|
||||
TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null)
|
||||
[ -n "$TENANT_TOKEN" ] || fail "tenant token fetch failed: $(echo "$TT_RESP" | head -c 200)"
|
||||
|
||||
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
|
||||
case "$CP_HOST" in
|
||||
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
|
||||
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
|
||||
*) DERIVED_DOMAIN="$CP_HOST" ;;
|
||||
esac
|
||||
TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}"
|
||||
log " tenant url: $TENANT_URL"
|
||||
|
||||
log "3b. waiting for tenant /health (TLS/DNS, up to 10min)..."
|
||||
for i in $(seq 1 120); do
|
||||
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# ─── 4. Provision the parent + one sibling per runtime under test ──────
|
||||
# Inject the LLM provider key so each runtime can authenticate at boot.
|
||||
# Priority: MiniMax → direct-Anthropic → OpenAI (mirrors
|
||||
# test_staging_full_saas.sh's secrets-injection chain).
|
||||
SECRETS_JSON='{}'
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_MINIMAX_API_KEY'];print(json.dumps({'ANTHROPIC_BASE_URL':'https://api.minimax.io/anthropic','ANTHROPIC_AUTH_TOKEN':k,'MINIMAX_API_KEY':k}))")
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_ANTHROPIC_API_KEY'];print(json.dumps({'ANTHROPIC_API_KEY':k}))")
|
||||
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_OPENAI_API_KEY'];print(json.dumps({'OPENAI_API_KEY':k,'OPENAI_BASE_URL':'https://api.openai.com/v1','MODEL_PROVIDER':'openai:gpt-4o','HERMES_INFERENCE_PROVIDER':'custom','HERMES_CUSTOM_BASE_URL':'https://api.openai.com/v1','HERMES_CUSTOM_API_KEY':k,'HERMES_CUSTOM_API_MODE':'chat_completions'}))")
|
||||
fi
|
||||
|
||||
log "4/6 provisioning parent (claude-code) + one sibling per runtime under test..."
|
||||
P_RESP=$(tenant_call POST /workspaces \
|
||||
-d "{\"name\":\"pv-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$SECRETS_JSON}")
|
||||
PARENT_ID=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$PARENT_ID" ] || fail "parent create failed: $(echo "$P_RESP" | head -c 300)"
|
||||
log " PARENT_ID=$PARENT_ID"
|
||||
|
||||
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
|
||||
declare -A WS_IDS WS_TOKENS
|
||||
ALL_WS_IDS="$PARENT_ID"
|
||||
for rt in $PV_RUNTIMES; do
|
||||
R=$(tenant_call POST /workspaces \
|
||||
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
|
||||
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
# auth_token is top-level for container runtimes; external-like nest it
|
||||
# under connection.auth_token (verified vs staging response shape).
|
||||
WTOK=$(echo "$R" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
|
||||
" 2>/dev/null)
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
|
||||
[ -n "$WTOK" ] || fail "$rt workspace did not return an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
|
||||
WS_IDS[$rt]="$WID"
|
||||
WS_TOKENS[$rt]="$WTOK"
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
log " $rt → $WID"
|
||||
done
|
||||
|
||||
# ─── 5. Wait for every sibling online ──────────────────────────────────
|
||||
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
|
||||
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
||||
for rt in $PV_RUNTIMES; do
|
||||
wid="${WS_IDS[$rt]}"
|
||||
LAST=""
|
||||
while true; do
|
||||
[ "$(date +%s)" -gt "$WS_DEADLINE" ] && fail "$rt ($wid) never reached online (last=$LAST)"
|
||||
S=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: sys.exit(0)
|
||||
w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d
|
||||
print(w.get('status') or '')
|
||||
" 2>/dev/null)
|
||||
[ "$S" != "$LAST" ] && { log " $rt → $S"; LAST="$S"; }
|
||||
case "$S" in
|
||||
online) break ;;
|
||||
failed) sleep 10 ;; # transient: bootstrap-watcher 5-min deadline, heartbeat recovers
|
||||
*) sleep 10 ;;
|
||||
esac
|
||||
done
|
||||
ok " $rt online"
|
||||
done
|
||||
|
||||
# ─── 6. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
|
||||
# This is the byte-for-byte user-facing call. NOT GET /registry/:id/peers,
|
||||
# NOT /health, NOT the heartbeat table. JSON-RPC 2.0 tools/call,
|
||||
# name=list_peers, authenticated by the workspace's OWN bearer token
|
||||
# through WorkspaceAuth + MCPRateLimiter.
|
||||
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
|
||||
echo ""
|
||||
RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
|
||||
REGRESSED=0
|
||||
declare -A VERDICT
|
||||
|
||||
for rt in $PV_RUNTIMES; do
|
||||
wid="${WS_IDS[$rt]}"
|
||||
wtok="${WS_TOKENS[$rt]}"
|
||||
# The expected peer set = every OTHER provisioned workspace (parent +
|
||||
# the sibling runtimes), excluding the caller itself.
|
||||
EXPECT_IDS=$(echo "$ALL_WS_IDS" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
|
||||
|
||||
set +e
|
||||
RESP=$(curl -sS -X POST "$TENANT_URL/workspaces/$wid/mcp" \
|
||||
-H "Authorization: Bearer $wtok" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RPC_BODY" \
|
||||
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
|
||||
set -e
|
||||
HTTP_CODE="$RESP"
|
||||
BODY=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
|
||||
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " HTTP $HTTP_CODE"
|
||||
echo " body: $(echo "$BODY" | head -c 600)"
|
||||
|
||||
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo " ✗ $rt: list_peers MCP call returned HTTP $HTTP_CODE (expected 200)"
|
||||
VERDICT[$rt]="FAIL(http=$HTTP_CODE)"
|
||||
REGRESSED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# (2) JSON-RPC result present, not an error object.
|
||||
PARSE=$(echo "$BODY" | python3 -c "
|
||||
import sys, json
|
||||
expect = set(filter(None, '''$EXPECT_IDS'''.split()))
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print('PARSE_ERROR:' + str(e)); sys.exit(0)
|
||||
if isinstance(d, dict) and d.get('error') is not None:
|
||||
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
|
||||
res = d.get('result') if isinstance(d, dict) else None
|
||||
if res is None:
|
||||
print('NO_RESULT'); sys.exit(0)
|
||||
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
|
||||
text = ''
|
||||
if isinstance(res, dict):
|
||||
for c in res.get('content', []):
|
||||
if c.get('type') == 'text':
|
||||
text += c.get('text', '')
|
||||
text_l = text.lower()
|
||||
# Native-sessions fallback signature (the OpenClaw symptom): the agent
|
||||
# answered from its own runtime session list, not the platform peer set.
|
||||
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
|
||||
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
|
||||
# The expected sibling IDs must literally appear in the returned peer text.
|
||||
found = sorted(i for i in expect if i in text)
|
||||
missing = sorted(expect - set(found))
|
||||
if not expect:
|
||||
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
|
||||
if missing:
|
||||
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
|
||||
sys.exit(0)
|
||||
print('OK:found=%d/%d' % (len(found), len(expect)))
|
||||
" 2>/dev/null)
|
||||
|
||||
case "$PARSE" in
|
||||
OK:*)
|
||||
echo " ✓ $rt: list_peers returned 200 and contains all expected peers ($PARSE)"
|
||||
VERDICT[$rt]="OK"
|
||||
;;
|
||||
NATIVE_FALLBACK:*)
|
||||
echo " ✗ $rt: list_peers fell back to NATIVE sessions — sees no platform peers ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(native-fallback)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
|
||||
echo " ✗ $rt: list_peers MCP call did not return a usable result ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(rpc=$PARSE)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
MISSING_PEERS:*)
|
||||
echo " ✗ $rt: list_peers returned 200 but peer set is wrong/empty ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(peers=$PARSE)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
*)
|
||||
echo " ✗ $rt: unexpected verdict '$PARSE'"
|
||||
VERDICT[$rt]="FAIL(unknown)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "=== SUMMARY — fresh-provision peer-visibility (literal MCP list_peers) ==="
|
||||
for rt in $PV_RUNTIMES; do
|
||||
printf ' %-14s %s\n' "$rt" "${VERDICT[$rt]:-NO_RUN}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
if [ "$REGRESSED" -ne 0 ]; then
|
||||
echo "✗ GATE FAILED — at least one runtime cannot see its peers via the"
|
||||
echo " literal mcp_molecule_list_peers call. This is the real user-facing"
|
||||
echo " failure the proxy signals (registry row / heartbeat / model 200)"
|
||||
echo " were hiding. Expected RED until the Hermes-401 + OpenClaw-MCP-wiring"
|
||||
echo " root-cause fixes land; goes green only when they actually do."
|
||||
exit 10
|
||||
fi
|
||||
|
||||
ok "GATE PASSED — every runtime under test sees its platform peers via the literal MCP call."
|
||||
exit 0
|
||||
@@ -402,7 +402,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
|
||||
return err
|
||||
}
|
||||
|
||||
adapter, ok := GetSendAdapter(ch.ChannelType)
|
||||
adapter, ok := GetAdapter(ch.ChannelType)
|
||||
if !ok {
|
||||
return fmt.Errorf("no adapter for %s", ch.ChannelType)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package channels
|
||||
|
||||
import "context"
|
||||
|
||||
// Registry of all available channel adapters.
|
||||
// To add a new platform: implement ChannelAdapter, register here.
|
||||
var adapters = map[string]ChannelAdapter{
|
||||
@@ -11,27 +9,6 @@ var adapters = map[string]ChannelAdapter{
|
||||
"discord": &DiscordAdapter{},
|
||||
}
|
||||
|
||||
// SendAdapter is the subset of ChannelAdapter needed by SendOutbound.
|
||||
// Extracted so tests can inject a no-op/mock adapter without hitting real
|
||||
// platform APIs (Telegram Bot API, Slack API, etc.).
|
||||
type SendAdapter interface {
|
||||
SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error
|
||||
}
|
||||
|
||||
// getSendAdapter is the production implementation of GetSendAdapter —
|
||||
// returns the real registered adapter's SendMessage method.
|
||||
func getSendAdapter(channelType string) (SendAdapter, bool) {
|
||||
a, ok := adapters[channelType]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// GetSendAdapter returns the SendAdapter for a channel type.
|
||||
// Defaults to the real adapter; overridden by SetTestSendAdapter in tests.
|
||||
var GetSendAdapter = getSendAdapter
|
||||
|
||||
// GetAdapter returns the adapter for a channel type.
|
||||
func GetAdapter(channelType string) (ChannelAdapter, bool) {
|
||||
a, ok := adapters[channelType]
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package channels
|
||||
|
||||
import "context"
|
||||
|
||||
// MockSendAdapter implements SendAdapter for handler tests. It records every
|
||||
// call and returns a configurable error (nil = success, non-nil = failure).
|
||||
type MockSendAdapter struct {
|
||||
Calls int
|
||||
Err error
|
||||
SentText string
|
||||
SentChat string
|
||||
}
|
||||
|
||||
func (m *MockSendAdapter) SendMessage(_ context.Context, _ map[string]interface{}, chatID string, text string) error {
|
||||
m.Calls++
|
||||
m.SentText = text
|
||||
m.SentChat = chatID
|
||||
return m.Err
|
||||
}
|
||||
|
||||
// SetGetSendAdapter replaces the package-level GetSendAdapter variable.
|
||||
// Tests MUST call ResetSendAdapters() in their t.Cleanup.
|
||||
func SetGetSendAdapter(fn func(string) (SendAdapter, bool)) {
|
||||
GetSendAdapter = fn
|
||||
}
|
||||
|
||||
// ResetSendAdapters restores GetSendAdapter to the production implementation.
|
||||
func ResetSendAdapters() {
|
||||
GetSendAdapter = getSendAdapter
|
||||
}
|
||||
@@ -97,28 +97,28 @@ const maxProxyResponseBody = 10 << 20
|
||||
//
|
||||
// Timeout model — three independent budgets, none of which gets in each other's way:
|
||||
//
|
||||
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
|
||||
// the entire request including streamed body reads, and would pre-empt
|
||||
// legitimate slow cold-start flows (Claude Code first-token over OAuth
|
||||
// can take 30-60s on boot; long-running agent synthesis can stream
|
||||
// tokens for minutes). Total-request budget is enforced per-request
|
||||
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
|
||||
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
|
||||
// the entire request including streamed body reads, and would pre-empt
|
||||
// legitimate slow cold-start flows (Claude Code first-token over OAuth
|
||||
// can take 30-60s on boot; long-running agent synthesis can stream
|
||||
// tokens for minutes). Total-request budget is enforced per-request
|
||||
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
|
||||
//
|
||||
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
|
||||
// black-holes TCP connects (instance terminated mid-flight, security group
|
||||
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
|
||||
// enough that Cloudflare's ~100s edge timeout can fire first and surface
|
||||
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
||||
// latencies and well below CF's edge timeout.
|
||||
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
|
||||
// black-holes TCP connects (instance terminated mid-flight, security group
|
||||
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
|
||||
// enough that Cloudflare's ~100s edge timeout can fire first and surface
|
||||
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
||||
// latencies and well below CF's edge timeout.
|
||||
//
|
||||
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
||||
// to response-headers-start. Configurable via
|
||||
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
||||
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
||||
// turns (big context + internal delegate_task round-trips routinely exceed
|
||||
// the old 60s ceiling). Body streaming after headers is governed by the
|
||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||
// responses still work fine.
|
||||
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
||||
// to response-headers-start. Configurable via
|
||||
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
||||
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
||||
// turns (big context + internal delegate_task round-trips routinely exceed
|
||||
// the old 60s ceiling). Body streaming after headers is governed by the
|
||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||
// responses still work fine.
|
||||
//
|
||||
// The point of (2) and (3) is to surface a *structured* 503 from
|
||||
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
|
||||
|
||||
@@ -194,11 +194,6 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
// Tracked via goAsync (not bare `go`) so the asyncWG can be drained
|
||||
// before a test swaps the global db.DB. runRestartCycle reads db.DB
|
||||
// before its provisioner gate, so an untracked detached goroutine
|
||||
// races setupTestDB's t.Cleanup db.DB restore. Matches the already-
|
||||
// correct site at a2a_proxy.go:648.
|
||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
||||
return true
|
||||
}
|
||||
@@ -246,9 +241,6 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
// Tracked via goAsync (see maybeMarkContainerDead): preflight's
|
||||
// detached restart must be drainable so it doesn't race the global
|
||||
// db.DB swap in test cleanup.
|
||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
||||
return &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
@@ -270,9 +262,8 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
errWsName = workspaceID
|
||||
}
|
||||
summary := "A2A request to " + errWsName + " failed: " + errMsg
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -318,9 +309,8 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
}
|
||||
summary := a2aMethod + " → " + wsNameForLog
|
||||
toolTrace := extractToolTrace(respBody)
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -520,9 +510,8 @@ func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID,
|
||||
wsName = workspaceID
|
||||
}
|
||||
summary := a2aMethod + " → " + wsName + " (queued for poll)"
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
|
||||
@@ -54,6 +54,7 @@ func TestPreflight_ContainerRunning_ReturnsNil(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
stub := &preflightLocalProv{running: true, err: nil}
|
||||
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, h)
|
||||
h.provisioner = stub
|
||||
|
||||
if err := h.preflightContainerHealth(context.Background(), "ws-running-123"); err != nil {
|
||||
@@ -186,8 +187,8 @@ func TestProxyA2A_Preflight_RoutesThroughProvisionerSSOT(t *testing.T) {
|
||||
}
|
||||
|
||||
var (
|
||||
callsIsRunning bool
|
||||
callsContainerInspectRaw bool
|
||||
callsIsRunning bool
|
||||
callsContainerInspectRaw bool
|
||||
callsRunningContainerNameDirect bool
|
||||
)
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
|
||||
@@ -262,6 +262,7 @@ func TestProxyA2A_Upstream502_TriggersContainerDeadCheck(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
cp := &fakeCPProv{running: false}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
@@ -324,6 +325,7 @@ func TestProxyA2A_Upstream502_AliveAgent_PropagatesAsIs(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
cp := &fakeCPProv{running: true}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
@@ -513,6 +515,7 @@ func TestProxyA2A_AllowedSelf_SkipsAccessCheck(t *testing.T) {
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -661,18 +664,18 @@ func TestProxyA2A_CallerIDDerivedFromBearer(t *testing.T) {
|
||||
// (column order: workspace_id, activity_type, source_id, target_id, ...)
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
"ws-target", // $1 workspace_id
|
||||
"a2a_receive", // $2 activity_type
|
||||
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
|
||||
sqlmock.AnyArg(), // $4 target_id
|
||||
sqlmock.AnyArg(), // $5 method
|
||||
sqlmock.AnyArg(), // $6 summary
|
||||
sqlmock.AnyArg(), // $7 request_body
|
||||
sqlmock.AnyArg(), // $8 response_body
|
||||
sqlmock.AnyArg(), // $9 tool_trace
|
||||
sqlmock.AnyArg(), // $10 duration_ms
|
||||
sqlmock.AnyArg(), // $11 status
|
||||
sqlmock.AnyArg(), // $12 error_detail
|
||||
"ws-target", // $1 workspace_id
|
||||
"a2a_receive", // $2 activity_type
|
||||
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
|
||||
sqlmock.AnyArg(), // $4 target_id
|
||||
sqlmock.AnyArg(), // $5 method
|
||||
sqlmock.AnyArg(), // $6 summary
|
||||
sqlmock.AnyArg(), // $7 request_body
|
||||
sqlmock.AnyArg(), // $8 response_body
|
||||
sqlmock.AnyArg(), // $9 tool_trace
|
||||
sqlmock.AnyArg(), // $10 duration_ms
|
||||
sqlmock.AnyArg(), // $11 status
|
||||
sqlmock.AnyArg(), // $12 error_detail
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -1716,7 +1719,6 @@ func TestDispatchA2A_RejectsUnsafeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- handleA2ADispatchError ---
|
||||
|
||||
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
@@ -1803,6 +1805,7 @@ func TestMaybeMarkContainerDead_CPOnly_NotRunning(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
cp := &fakeCPProv{running: false}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
@@ -1955,6 +1958,7 @@ func TestLogA2AFailure_Smoke(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
// Sync workspace-name lookup (called in the caller goroutine).
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
@@ -1973,6 +1977,7 @@ func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
// Empty name from DB → summary uses the workspaceID as the name.
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
@@ -1989,6 +1994,7 @@ func TestLogA2ASuccess_Smoke(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ok").
|
||||
@@ -2005,6 +2011,7 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-err").
|
||||
|
||||
@@ -26,6 +26,10 @@ import (
|
||||
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
|
||||
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
|
||||
// Uses the same global db.DB as setupTestDB so the handler can use it.
|
||||
//
|
||||
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
|
||||
// that tests running after this one are not polluted by a closed mock.
|
||||
// Same fix as setupTestDB (handlers_test.go); same root cause as mc#975.
|
||||
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
@@ -81,54 +85,6 @@ func TestExtractIdempotencyKey_emptyOnMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// extractExpiresInSeconds
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractExpiresInSeconds_valid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"positive int", `{"params":{"expires_in_seconds":30}}`, 30},
|
||||
{"zero", `{"params":{"expires_in_seconds":0}}`, 0},
|
||||
{"large TTL", `{"params":{"expires_in_seconds":3600}}`, 3600},
|
||||
{"nested message — not affected", `{"params":{"message":{"role":"user"},"expires_in_seconds":60}}`, 60},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractExpiresInSeconds_invalidOrMissing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"negative → 0", `{"params":{"expires_in_seconds":-5}}`, 0},
|
||||
{"missing expires_in_seconds", `{"params":{"message":{"role":"user"}}}`, 0},
|
||||
{"no params at all", `{"method":"message/send"}`, 0},
|
||||
{"malformed JSON", `not json`, 0},
|
||||
{"empty body", ``, 0},
|
||||
{"null value", `{"params":{"expires_in_seconds":null}}`, 0},
|
||||
{"string value", `{"params":{"expires_in_seconds":"30"}}`, 0},
|
||||
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 30},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds(%q) = %d, want %d", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDelegationIDFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -116,9 +116,6 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListPendingApprovals rows.Err: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
@@ -158,9 +155,6 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListApprovals rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
|
||||
@@ -328,207 +328,6 @@ func TestChannelHandler_Send_EmptyText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Test (send outbound) ====================
|
||||
|
||||
// TestChannelHandler_Test_Success exercises the /channels/:channelId/test endpoint
|
||||
// with a mock SendAdapter so the full success path is covered without hitting real
|
||||
// Telegram/Slack/etc. APIs.
|
||||
func TestChannelHandler_Test_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
mockAdapter := &channels.MockSendAdapter{Err: nil}
|
||||
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
|
||||
if ct == "telegram" {
|
||||
return mockAdapter, true
|
||||
}
|
||||
return channels.GetSendAdapter(ct)
|
||||
})
|
||||
t.Cleanup(channels.ResetSendAdapters)
|
||||
|
||||
// loadChannel → valid row
|
||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
||||
WithArgs("ch-test-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "channel_type", "channel_config",
|
||||
"enabled", "allowed_users",
|
||||
}).AddRow("ch-test-ok", "ws-1", "telegram",
|
||||
`{"bot_token":"123:AAA","chat_id":"-100"}`,
|
||||
true, `[]`))
|
||||
|
||||
// UPDATE message_count + last_message_at
|
||||
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-test-ok/test", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-test-ok"}}
|
||||
|
||||
handler.Test(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "ok" {
|
||||
t.Errorf("expected status 'ok', got %v", resp["status"])
|
||||
}
|
||||
if mockAdapter.Calls != 1 {
|
||||
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
|
||||
}
|
||||
if mockAdapter.SentChat != "-100" {
|
||||
t.Errorf("expected chat_id '-100', got %q", mockAdapter.SentChat)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelHandler_Test_ChannelNotFound verifies that when loadChannel returns
|
||||
// no rows, the Test handler returns 500 with a "test message failed" error.
|
||||
func TestChannelHandler_Test_ChannelNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
// loadChannel → no rows
|
||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
||||
WithArgs("ch-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "channel_type", "channel_config",
|
||||
"enabled", "allowed_users",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-missing/test", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-missing"}}
|
||||
|
||||
handler.Test(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["error"] != "test message failed" {
|
||||
t.Errorf("expected error 'test message failed', got %v", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelHandler_Send_Success covers the full outbound send success path:
|
||||
// budget check passes → loadChannel → mock SendMessage succeeds → UPDATE count → 200.
|
||||
func TestChannelHandler_Send_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
mockAdapter := &channels.MockSendAdapter{Err: nil}
|
||||
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
|
||||
if ct == "telegram" {
|
||||
return mockAdapter, true
|
||||
}
|
||||
return channels.GetSendAdapter(ct)
|
||||
})
|
||||
t.Cleanup(channels.ResetSendAdapters)
|
||||
|
||||
// Budget check: count=0, no budget limit
|
||||
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
|
||||
WithArgs("ch-send-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
|
||||
AddRow(0, nil))
|
||||
|
||||
// loadChannel → valid row
|
||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
||||
WithArgs("ch-send-ok").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "channel_type", "channel_config",
|
||||
"enabled", "allowed_users",
|
||||
}).AddRow("ch-send-ok", "ws-1", "telegram",
|
||||
`{"bot_token":"123:AAA","chat_id":"-100"}`,
|
||||
true, `[]`))
|
||||
|
||||
// UPDATE message_count
|
||||
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"text": "hello from test"})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-ok/send", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-ok"}}
|
||||
|
||||
handler.Send(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "sent" {
|
||||
t.Errorf("expected status 'sent', got %v", resp["status"])
|
||||
}
|
||||
if mockAdapter.Calls != 1 {
|
||||
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
|
||||
}
|
||||
if mockAdapter.SentText != "hello from test" {
|
||||
t.Errorf("expected 'hello from test', got %q", mockAdapter.SentText)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelHandler_Send_ChannelNotFound verifies that after the budget check
|
||||
// passes, a missing channel returns 500 (not 404) with "send failed".
|
||||
func TestChannelHandler_Send_ChannelNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
// Budget check passes (NULL budget → no limit)
|
||||
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
|
||||
WithArgs("ch-send-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
|
||||
AddRow(0, nil))
|
||||
|
||||
// loadChannel → no rows
|
||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
||||
WithArgs("ch-send-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "channel_type", "channel_config",
|
||||
"enabled", "allowed_users",
|
||||
}))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"text": "hello"})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-missing/send", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-missing"}}
|
||||
|
||||
handler.Send(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["error"] != "send failed" {
|
||||
t.Errorf("expected error 'send failed', got %v", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Webhook ====================
|
||||
|
||||
func TestChannelHandler_Webhook_UnknownType(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -698,7 +699,8 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
|
||||
var delegationID, callerID, calleeID, taskPreview, status string
|
||||
var resultPreview, errorDetail sql.NullString
|
||||
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
|
||||
if err := rows.Scan(
|
||||
&delegationID, &callerID, &calleeID, &taskPreview,
|
||||
@@ -717,11 +719,11 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
|
||||
"updated_at": updatedAt,
|
||||
"_ledger": true, // marker so callers know this row is from the ledger
|
||||
}
|
||||
if resultPreview != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
|
||||
if resultPreview.Valid && resultPreview.String != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview.String, 300)
|
||||
}
|
||||
if errorDetail != "" {
|
||||
entry["error"] = errorDetail
|
||||
if errorDetail.Valid && errorDetail.String != "" {
|
||||
entry["error"] = errorDetail.String
|
||||
}
|
||||
if lastHeartbeat != nil {
|
||||
entry["last_heartbeat"] = lastHeartbeat
|
||||
|
||||
@@ -145,6 +145,54 @@ func TestListDelegationsFromLedger_MultipleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDelegationsFromLedger_NullsOmitted(t *testing.T) {
|
||||
// last_heartbeat, deadline, result_preview, error_detail are all NULL.
|
||||
// Handler must not panic and must omit those keys from the map.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
||||
|
||||
now := time.Now()
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail",
|
||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", nil, nil, nil, nil, now, now)
|
||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(rows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(got))
|
||||
}
|
||||
e := got[0]
|
||||
if _, ok := e["last_heartbeat"]; ok {
|
||||
t.Error("last_heartbeat should be absent when NULL")
|
||||
}
|
||||
if _, ok := e["deadline"]; ok {
|
||||
t.Error("deadline should be absent when NULL")
|
||||
}
|
||||
if _, ok := e["response_preview"]; ok {
|
||||
t.Error("response_preview should be absent when NULL result_preview")
|
||||
}
|
||||
if _, ok := e["error"]; ok {
|
||||
t.Error("error should be absent when NULL error_detail")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDelegationsFromLedger_QueryError(t *testing.T) {
|
||||
// Query failure returns nil — graceful fallback, no panic.
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
@@ -438,10 +486,3 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListDelegationsFromActivityLogs_ScanErrorSkipped is removed.
|
||||
//
|
||||
// Same reason as TestListDelegationsFromLedger_ScanError: Go 1.25 causes
|
||||
// sqlmock.NewRows([]string{}).AddRow(...) to panic in test SETUP. The handler
|
||||
// has no recover(), so a scan panic would crash the process — the correct
|
||||
// behaviour. Real-DB integration tests cover this path.
|
||||
|
||||
@@ -646,8 +646,12 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path.
|
||||
# external machine today, pair with the Python SDK tab.
|
||||
|
||||
# 1. Install openclaw CLI + the workspace runtime wheel:
|
||||
# The version pin (>=0.1.999) ensures the "molecule-mcp" console
|
||||
# script is present — it is what keeps the workspace ALIVE on canvas
|
||||
# (register-on-startup + 20s heartbeat). Older versions only ship
|
||||
# a2a_mcp_server which does not heartbeat.
|
||||
npm install -g openclaw@latest
|
||||
pip install molecule-ai-workspace-runtime
|
||||
pip install "molecule-ai-workspace-runtime>=0.1.999"
|
||||
|
||||
# 2. Onboard openclaw against your model provider (one-time setup).
|
||||
# --non-interactive needs an explicit --provider + --model so it
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,44 +22,18 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// liveTestHandlers tracks every WorkspaceHandler built during the test
|
||||
// binary's lifetime so setupTestDB can drain their in-flight goAsync
|
||||
// goroutines (notably the detached RestartByID restart cycle, which
|
||||
// reads the global db.DB) BEFORE restoring db.DB. Without this drain a
|
||||
// fire-and-forget restart goroutine spawned by one test outlives that
|
||||
// test and races the db.DB swap in a later test's t.Cleanup — the
|
||||
// 0x...d548 data race on platform/internal/db.DB.
|
||||
var (
|
||||
liveTestHandlersMu sync.Mutex
|
||||
liveTestHandlers []*WorkspaceHandler
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
newHandlerHook = func(h *WorkspaceHandler) {
|
||||
liveTestHandlersMu.Lock()
|
||||
liveTestHandlers = append(liveTestHandlers, h)
|
||||
liveTestHandlersMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drainTestAsync waits for every tracked handler's goAsync goroutines to
|
||||
// finish. Called from setupTestDB's cleanup before db.DB is restored so
|
||||
// no detached restart/provision goroutine is mid-read of db.DB when the
|
||||
// pointer is swapped.
|
||||
func drainTestAsync() {
|
||||
liveTestHandlersMu.Lock()
|
||||
handlers := make([]*WorkspaceHandler, len(liveTestHandlers))
|
||||
copy(handlers, liveTestHandlers)
|
||||
liveTestHandlersMu.Unlock()
|
||||
for _, h := range handlers {
|
||||
h.waitAsyncForTest()
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
|
||||
// It also disables the SSRF URL check so that httptest.NewServer loopback
|
||||
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
|
||||
//
|
||||
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
|
||||
// that tests running after this one are not polluted by a closed mock.
|
||||
// This is the single root cause of the systemic CI/Platform (Go) failures on
|
||||
// main HEAD 8026f020 (mc#975).
|
||||
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
@@ -69,16 +42,7 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() {
|
||||
// Drain detached async goroutines (e.g. goAsync(RestartByID),
|
||||
// which reads db.DB in runRestartCycle before its provisioner
|
||||
// gate) BEFORE swapping db.DB back. Doing the restore first
|
||||
// would let an in-flight restart goroutine read db.DB while
|
||||
// this line writes it — the data race this guards against.
|
||||
drainTestAsync()
|
||||
db.DB = prevDB
|
||||
mockDB.Close()
|
||||
})
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
// Disable SSRF checks for the duration of this test only. Restore
|
||||
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
|
||||
@@ -98,6 +62,11 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||
return mock
|
||||
}
|
||||
|
||||
func waitForHandlerAsyncBeforeDBCleanup(t *testing.T, h *WorkspaceHandler) {
|
||||
t.Helper()
|
||||
t.Cleanup(h.waitAsyncForTest)
|
||||
}
|
||||
|
||||
// setupTestRedis creates a miniredis instance and assigns it to the global db.RDB.
|
||||
func setupTestRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
@@ -397,6 +366,11 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
|
||||
WithArgs("claude-code").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
|
||||
@@ -248,9 +248,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ResolveInstructions rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workspace_id": workspaceID,
|
||||
@@ -261,7 +258,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
func scanInstructions(rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Err() error
|
||||
}) []Instruction {
|
||||
var instructions []Instruction
|
||||
for rows.Next() {
|
||||
@@ -273,9 +269,6 @@ func scanInstructions(rows interface {
|
||||
}
|
||||
instructions = append(instructions, inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("scanInstructions rows.Err: %v", err)
|
||||
}
|
||||
if instructions == nil {
|
||||
instructions = []Instruction{}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -80,117 +82,135 @@ func TestInstructionsList_ByWorkspaceID(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Errorf("expected 2 instructions, got %d", len(out))
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 instructions, got %d", len(result))
|
||||
}
|
||||
if out[0].Scope != "global" {
|
||||
t.Errorf("first row scope: expected global, got %s", out[0].Scope)
|
||||
if result[0].Scope != "global" || result[1].Scope != "workspace" {
|
||||
t.Fatalf("expected global then workspace instructions, got %#v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_ByScope(t *testing.T) {
|
||||
func TestInstructionsHandler_List_WithScopeFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions?scope=global")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?scope=global", nil)
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}).AddRow("inst-1", "global", nil, "Be kind", "Always be kind", 10, true,
|
||||
time.Now(), time.Now())
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols).
|
||||
AddRow("inst-g", "global", nil, "Global Rule", "Follow policy.", 10, true, time.Now(), time.Now())
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1 AND scope = $1 ORDER BY scope, priority DESC, created_at")).
|
||||
WithArgs("global").
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions?scope=global", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Scope != "global" {
|
||||
t.Errorf("unexpected response: %v", out)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 instruction, got %d", len(result))
|
||||
}
|
||||
if result[0].Scope != "global" {
|
||||
t.Errorf("expected scope 'global', got %q", result[0].Scope)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_AllNoParams(t *testing.T) {
|
||||
func TestInstructionsHandler_List_WithWorkspaceID(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-test-123"
|
||||
|
||||
w, c := newGetRequest("/instructions")
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}).AddRow("inst-1", "global", nil, "Global rule", "Stay safe", 5, true,
|
||||
time.Now(), time.Now()).
|
||||
AddRow("inst-2", "workspace", &wsID, "WS rule", "Use HTTPS", 10, true,
|
||||
time.Now(), time.Now())
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols)
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE enabled = true AND \\(").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions?workspace_id="+wsID, nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
// Empty slice, not nil
|
||||
if out == nil {
|
||||
t.Error("expected empty slice, got nil")
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 instructions, got %d", len(result))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_DBError(t *testing.T) {
|
||||
func TestInstructionsHandler_List_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions", nil)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
h.List(c)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
// ── Create ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_ValidGlobal(t *testing.T) {
|
||||
func TestInstructionsHandler_Create_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Be Helpful",
|
||||
"content": "Always be helpful to the user.",
|
||||
"priority": 10,
|
||||
})
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Be Helpful", "Always be helpful to the user.", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-1"))
|
||||
WithArgs("global", nil, "Be kind", "Always be kind", 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-id"))
|
||||
|
||||
h.Create(c)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Be kind",
|
||||
"content": "Always be kind",
|
||||
"priority": 5,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -199,8 +219,8 @@ func TestInstructionsCreate_ValidGlobal(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if out["id"] != "new-inst-1" {
|
||||
t.Errorf("expected id new-inst-1, got %s", out["id"])
|
||||
if out["id"] != "new-inst-id" {
|
||||
t.Errorf("expected id new-inst-id, got %s", out["id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
@@ -299,56 +319,65 @@ func TestInstructionsCreate_InvalidScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeNoTarget(t *testing.T) {
|
||||
func TestInstructionsHandler_Create_WorkspaceScopeMissingScopeTarget(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"title": "Missing Target",
|
||||
"content": "Workspace scope without scope_target.",
|
||||
"title": "Test",
|
||||
"content": "Test content",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
|
||||
func TestInstructionsHandler_Create_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
// Build a string longer than maxInstructionContentLen (8192).
|
||||
longContent := string(make([]byte, maxInstructionContentLen+1))
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
longContent := string(bytes.Repeat([]byte("x"), 8193))
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Too Long",
|
||||
"title": "Test",
|
||||
"content": longContent,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
|
||||
func TestInstructionsHandler_Create_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longTitle := string(make([]byte, 201))
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
longTitle := string(bytes.Repeat([]byte("x"), 201))
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": longTitle,
|
||||
"content": "Short content.",
|
||||
"content": "Short content",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -842,43 +871,250 @@ func TestInstructionsResolve_ScopeTransitionOnlyGlobal(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// Two global instructions share one section header.
|
||||
if bytes.Count([]byte(out.Instructions), []byte("Platform-Wide Rules")) != 1 {
|
||||
t.Error("expect exactly one 'Platform-Wide Rules' header for consecutive global rows")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update: empty body (all nil — no-op update) ─────────────────────────────
|
||||
|
||||
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
|
||||
func TestInstructionsHandler_Update_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-empty-update"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
mock.ExpectExec(regexp.QuoteMeta("UPDATE platform_instructions SET\n\t\t\t\ttitle = COALESCE($2, title),\n\t\t\t\tcontent = COALESCE($3, content),\n\t\t\t\tpriority = COALESCE($4, priority),\n\t\t\t\tenabled = COALESCE($5, enabled),\n\t\t\t\tupdated_at = NOW()\n\t\t\t\tWHERE id = $1")).
|
||||
WithArgs("nonexistent", sqlmock.AnyArg(), nil, nil, nil).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
// COALESCE(nil, ...) = unchanged; still updates updated_at.
|
||||
// Args order: ($1=id, $2=title, $3=content, $4=priority, $5=enabled)
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(instID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
body, _ := json.Marshal(map[string]interface{}{"title": "Updated title"})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "nonexistent"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/nonexistent", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Update_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longContent := string(bytes.Repeat([]byte("x"), 8193))
|
||||
body, _ := json.Marshal(map[string]interface{}{"content": longContent})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Update_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
longTitle := string(bytes.Repeat([]byte("x"), 201))
|
||||
body, _ := json.Marshal(map[string]interface{}{"title": longTitle})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_Delete_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM platform_instructions WHERE id = $1")).
|
||||
WithArgs("inst-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/instructions/inst-1", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Delete_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM platform_instructions WHERE id = $1")).
|
||||
WithArgs("nonexistent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "nonexistent"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/instructions/nonexistent", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsHandler_Resolve_Empty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-resolve-1"
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions WHERE enabled = true AND").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"scope", "title", "content"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if resp["workspace_id"] != wsID {
|
||||
t.Errorf("expected workspace_id %q, got %v", wsID, resp["workspace_id"])
|
||||
}
|
||||
if resp["instructions"] != "" {
|
||||
t.Errorf("expected empty instructions, got %q", resp["instructions"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Resolve_WithInstructions(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
wsID := "ws-resolve-2"
|
||||
|
||||
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
|
||||
AddRow("global", "Be safe", "No SSRF").
|
||||
AddRow("workspace", "WS Rule", "Use HTTPS")
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions WHERE enabled = true AND").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
instructions, ok := resp["instructions"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("instructions field is not a string: %T", resp["instructions"])
|
||||
}
|
||||
if instructions == "" {
|
||||
t.Fatalf("expected non-empty instructions")
|
||||
}
|
||||
// Verify scope headers are present
|
||||
if !bytes.Contains([]byte(instructions), []byte("Platform-Wide Rules")) {
|
||||
t.Errorf("expected 'Platform-Wide Rules' header in instructions")
|
||||
}
|
||||
if !bytes.Contains([]byte(instructions), []byte("Role-Specific Rules")) {
|
||||
t.Errorf("expected 'Role-Specific Rules' header in instructions")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsHandler_Resolve_MissingWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: ""}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces//instructions/resolve", nil)
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// scanInstructions is called by the List handler — verify it handles
|
||||
// rows.Err() gracefully without panicking.
|
||||
func TestInstructionsHandler_List_ScanErrorContinues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewInstructionsHandler()
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
|
||||
}).AddRow("inst-1", "global", nil, "Good", "Content here", 5, true, time.Now(), time.Now()).
|
||||
RowError(1, context.DeadlineExceeded) // error on row 2 (if it existed)
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/instructions", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
// Should still return 200 and the one valid row
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var result []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
// The valid row should still be returned (error is logged, not fatal)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 instruction despite row error, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,62 +271,6 @@ func (e EnvRequirement) IsSatisfied(configured map[string]struct{}) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// perWorkspaceUnsatisfied records a single unsatisfied RequiredEnv for a
|
||||
// specific workspace during org import preflight.
|
||||
type perWorkspaceUnsatisfied struct {
|
||||
Workspace string
|
||||
FilesDir string
|
||||
Unsatisfied EnvRequirement
|
||||
}
|
||||
|
||||
// collectPerWorkspaceUnsatisfied walks the workspace tree and returns every
|
||||
// RequiredEnv that is neither in `configured` (global secrets) nor resolvable
|
||||
// from the org root or workspace-level .env file. An empty orgBaseDir skips
|
||||
// the .env walk so all requirements appear unsatisfied (used by tests to
|
||||
// isolate the global-only path).
|
||||
func collectPerWorkspaceUnsatisfied(
|
||||
workspaces []OrgWorkspace,
|
||||
orgBaseDir string,
|
||||
configured map[string]struct{},
|
||||
) []perWorkspaceUnsatisfied {
|
||||
var result []perWorkspaceUnsatisfied
|
||||
for _, ws := range workspaces {
|
||||
result = append(result, checkWorkspaceRequiredEnv(ws, orgBaseDir, configured)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func checkWorkspaceRequiredEnv(
|
||||
ws OrgWorkspace,
|
||||
orgBaseDir string,
|
||||
configured map[string]struct{},
|
||||
) []perWorkspaceUnsatisfied {
|
||||
var result []perWorkspaceUnsatisfied
|
||||
// Merge in .env vars from the org root and the workspace-specific dir.
|
||||
// Workspace-level vars override org-root vars, just as loadWorkspaceEnv
|
||||
// implements: org root first, then ws dir on top.
|
||||
if orgBaseDir != "" {
|
||||
wsEnv := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
|
||||
for k, v := range wsEnv {
|
||||
configured[k] = struct{}{}
|
||||
_ = v // value only used for merging into configured map
|
||||
}
|
||||
}
|
||||
for _, req := range ws.RequiredEnv {
|
||||
if !req.IsSatisfied(configured) {
|
||||
result = append(result, perWorkspaceUnsatisfied{
|
||||
Workspace: ws.Name,
|
||||
FilesDir: ws.FilesDir,
|
||||
Unsatisfied: req,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, child := range ws.Children {
|
||||
result = append(result, checkWorkspaceRequiredEnv(child, orgBaseDir, configured)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UnmarshalYAML accepts either a scalar (string → single) or a map
|
||||
// with an `any_of` list (→ group).
|
||||
func (e *EnvRequirement) UnmarshalYAML(value *yaml.Node) error {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// resolvePromptRef reads a prompt body from either an inline string or a
|
||||
// file ref relative to the workspace's files_dir. Inline always wins when
|
||||
// both are non-empty (caller-provided inline is more authoritative than a
|
||||
@@ -64,9 +65,7 @@ func resolvePromptRef(inline, fileRef, orgBaseDir, filesDir string) (string, err
|
||||
|
||||
// envVarRefPattern matches actual ${VAR} or $VAR references (not literal $).
|
||||
// Used to detect unresolved placeholders without false positives like "$5".
|
||||
// Requires [a-zA-Z_] as the first char after $ so $100 stays literal.
|
||||
// Two capture groups: (1) ${VAR} form, (2) $VAR form.
|
||||
var envVarRefPattern = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||
var envVarRefPattern = regexp.MustCompile(`\$\{?[A-Za-z_][A-Za-z0-9_]*\}?`)
|
||||
|
||||
// hasUnresolvedVarRef returns true if the original string had a ${VAR} or $VAR
|
||||
// reference that the expanded string didn't fully replace (i.e. the var was unset).
|
||||
@@ -133,6 +132,15 @@ func expandWithEnv(s string, env map[string]string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
func isEnvIdentStart(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
|
||||
}
|
||||
|
||||
func isEnvIdentPart(c byte) bool {
|
||||
return isEnvIdentStart(c) || (c >= '0' && c <= '9')
|
||||
}
|
||||
|
||||
// expandEnvRef resolves a single variable reference extracted from s.
|
||||
//
|
||||
// Guards:
|
||||
@@ -168,13 +176,6 @@ func expandEnvRef(key, ref, whole string, env map[string]string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
func isEnvIdentStart(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
|
||||
}
|
||||
|
||||
func isEnvIdentPart(c byte) bool {
|
||||
return isEnvIdentStart(c) || (c >= '0' && c <= '9')
|
||||
}
|
||||
|
||||
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env .env and the workspace-specific .env
|
||||
// (workspace overrides org root). Used by both secret injection and channel
|
||||
@@ -428,11 +429,7 @@ func resolveInsideRoot(root, userPath string) (string, error) {
|
||||
return "", fmt.Errorf("root abs: %w", err)
|
||||
}
|
||||
joined := filepath.Join(absRoot, userPath)
|
||||
// filepath.Join preserves "." components when root is absolute; clean
|
||||
// them before computing the final absolute path so "./subdir/./file.txt"
|
||||
// resolves to root/subdir/file.txt (not root/./subdir/./file.txt).
|
||||
cleaned := filepath.Clean(joined)
|
||||
absJoined, err := filepath.Abs(cleaned)
|
||||
absJoined, err := filepath.Abs(joined)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("joined abs: %w", err)
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
|
||||
// documents this design choice; callers who need empty=resolved should
|
||||
// pre-process the output before calling hasUnresolvedVarRef.
|
||||
{"${VAR}", "", true},
|
||||
{"${VAR}", "value", false}, // var replaced
|
||||
{"$VAR", "value", false}, // bare var replaced
|
||||
{"${VAR}", "value", false}, // var replaced
|
||||
{"$VAR", "value", false}, // bare var replaced
|
||||
{"prefix${VAR}suffix", "prefixvaluesuffix", false},
|
||||
{"${A}${B}", "ab", false},
|
||||
// FOO=FOO and BAR=BAR — both vars found and replaced. Expanded output
|
||||
@@ -125,14 +125,14 @@ func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
|
||||
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
|
||||
// Expansion left the refs intact → unresolved.
|
||||
cases := []struct {
|
||||
orig string
|
||||
orig string
|
||||
expanded string
|
||||
}{
|
||||
{"${VAR}", "${VAR}"}, // untouched
|
||||
{"$VAR", "$VAR"}, // bare untouched
|
||||
{"${VAR}", "${VAR}"}, // untouched
|
||||
{"$VAR", "$VAR"}, // bare untouched
|
||||
{"prefix${VAR}suffix", "prefix${VAR}suffix"},
|
||||
{"${A}${B}", "${A}${B}"}, // both unresolved
|
||||
{"${FOO}", ""}, // empty result with var ref in original
|
||||
{"${A}${B}", "${A}${B}"}, // both unresolved
|
||||
{"${FOO}", ""}, // empty result with var ref in original
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.orig, func(t *testing.T) {
|
||||
@@ -205,8 +205,8 @@ func TestMergeCategoryRouting_WorkspaceOverrides(t *testing.T) {
|
||||
"ui": {"Frontend Engineer"},
|
||||
}
|
||||
ws := map[string][]string{
|
||||
"security": {"SRE Team"}, // narrows
|
||||
"ui": {}, // drops
|
||||
"security": {"SRE Team"}, // narrows
|
||||
"ui": {}, // drops
|
||||
"infra": {"Platform Team"}, // adds
|
||||
}
|
||||
r := mergeCategoryRouting(defaults, ws)
|
||||
@@ -462,11 +462,47 @@ func TestExpandWithEnv_LiteralDollar(t *testing.T) {
|
||||
func TestExpandWithEnv_PartiallyPresent(t *testing.T) {
|
||||
env := map[string]string{"SET": "yes"}
|
||||
result := expandWithEnv("${SET} and ${NOT_SET}", env)
|
||||
// ${SET} resolved from env; ${NOT_SET} stays literal (not whole-string ref,
|
||||
// so os.Getenv fallback is NOT used — CWE-78 regression guard).
|
||||
assert.Equal(t, "yes and ${NOT_SET}", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_EmbeddedMissingProcessEnvStaysLiteral(t *testing.T) {
|
||||
t.Setenv("MOL_TEST_EMBEDDED_MISSING", "")
|
||||
|
||||
result := expandWithEnv("prefix/${MOL_TEST_EMBEDDED_MISSING}/suffix", map[string]string{})
|
||||
assert.Equal(t, "prefix/${MOL_TEST_EMBEDDED_MISSING}/suffix", result)
|
||||
}
|
||||
|
||||
// POSIX identifier guard regression tests (CWE-78 fix).
|
||||
// Keys not starting with [a-zA-Z_] must not be looked up in env or os.Getenv.
|
||||
func TestExpandWithEnv_DigitPrefix_NotExpanded(t *testing.T) {
|
||||
// ${0}, ${5}, ${1VAR} — numeric prefix → not a valid shell identifier.
|
||||
// Guard must return "$0", "$5", "$1VAR" literally; no env lookup.
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"${0}", "$0"},
|
||||
{"${5}", "$5"},
|
||||
{"${1VAR}", "$1VAR"},
|
||||
{"prefix ${0} suffix", "prefix $0 suffix"},
|
||||
{"$0", "$0"},
|
||||
{"$5", "$5"},
|
||||
{"HOME=${HOME}", "HOME=${HOME}"}, // HOME is valid but embedded in larger string
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := expandWithEnv(tc.input, map[string]string{})
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_EmptyKey_ReturnsDollar(t *testing.T) {
|
||||
// ${} → "$" (empty key, guard returns "$")
|
||||
result := expandWithEnv("value=${}", map[string]string{})
|
||||
assert.Equal(t, "value=$", result)
|
||||
}
|
||||
|
||||
// mergeCategoryRouting tests — unions defaults with per-workspace routing.
|
||||
|
||||
// ── Additional coverage: mergeCategoryRouting ──────────────────────
|
||||
@@ -546,8 +582,8 @@ func TestRenderCategoryRoutingYAML_SingleCategory(t *testing.T) {
|
||||
|
||||
func TestRenderCategoryRoutingYAML_MultipleCategoriesSorted(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"zebra": {"RoleZ"},
|
||||
"alpha": {"RoleA"},
|
||||
"zebra": {"RoleZ"},
|
||||
"alpha": {"RoleA"},
|
||||
"middleware": {"RoleM"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
@@ -590,7 +626,7 @@ func TestRenderCategoryRoutingYAML_SpecialCharactersEscaped(t *testing.T) {
|
||||
// ── Additional coverage: appendYAMLBlock ───────────────────────────
|
||||
func TestAppendYAMLBlock_BothEmpty(t *testing.T) {
|
||||
result := appendYAMLBlock(nil, "")
|
||||
assert.Nil(t, result) // append(nil, []byte("")...) returns nil in Go
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingHasNewline(t *testing.T) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestResolveInsideRoot_EmptyUserPath(t *testing.T) {
|
||||
_, err := resolveInsideRoot("/safe/root", "")
|
||||
if err == nil {
|
||||
t.Fatal("empty userPath: expected error, got nil")
|
||||
t.Fatalf("empty userPath: expected error, got nil")
|
||||
}
|
||||
if err.Error() != "path is empty" {
|
||||
t.Errorf("empty userPath: got %q, want %q", err.Error(), "path is empty")
|
||||
@@ -26,7 +26,7 @@ func TestResolveInsideRoot_EmptyUserPath(t *testing.T) {
|
||||
func TestResolveInsideRoot_AbsolutePathRejected(t *testing.T) {
|
||||
_, err := resolveInsideRoot("/safe/root", "/etc/passwd")
|
||||
if err == nil {
|
||||
t.Fatal("absolute userPath: expected error, got nil")
|
||||
t.Fatalf("absolute userPath: expected error, got nil")
|
||||
}
|
||||
if err.Error() != "absolute paths are not allowed" {
|
||||
t.Errorf("absolute userPath: got %q, want %q", err.Error(), "absolute paths are not allowed")
|
||||
@@ -44,6 +44,11 @@ func TestResolveInsideRoot_DotDotTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInsideRoot_DotDotWithIntermediate verifies that a/b/../../c does NOT
|
||||
// escape when root=/safe/root. After normalization: a/b/../.. = ., so a/b/../../c = c,
|
||||
// which is a valid descendant of /safe/root. The original test expected an error
|
||||
// but resolveInsideRoot correctly returns nil (the path stays within root).
|
||||
// The OFFSEC-006 concern is covered by ../../etc/passwd which DOES escape.
|
||||
func TestResolveInsideRoot_DotDotWithIntermediate(t *testing.T) {
|
||||
// a/b/../../c normalises to "c" — a valid descendant inside any root.
|
||||
// Must use t.TempDir() for a real filesystem path so filepath.Abs resolves.
|
||||
@@ -93,14 +98,16 @@ func TestResolveInsideRoot_DotPathComponent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dot path component: unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(got, "/subdir/file.txt") {
|
||||
t.Errorf("dot path component: got %q, want suffix /subdir/file.txt", got)
|
||||
// Verify the file component is subdir/file.txt regardless of root length.
|
||||
suffix := string(filepath.Separator) + "subdir" + string(filepath.Separator) + "file.txt"
|
||||
if !strings.HasSuffix(got, suffix) {
|
||||
t.Errorf("dot path component: got %q, want suffix %q", got, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInsideRoot_NestedDotDotEscapes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// a/../../b from /tmp/dirsomething → /tmp/b (escapes temp dir)
|
||||
// a/../../b from /tmp/xyz → /tmp/b (escapes temp dir)
|
||||
got, err := resolveInsideRoot(root, "a/../../b")
|
||||
if err == nil {
|
||||
t.Fatalf("nested dotdot: expected error, got %q", got)
|
||||
@@ -188,15 +195,17 @@ func TestIsSafeRoleName_SpecialChars(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── mergeCategoryRouting ──────────────────────────────────────────────────────
|
||||
// Duplicate mergeCategoryRouting tests removed to avoid redeclaration with
|
||||
// org_helpers_pure_test.go. Only security-specific behaviour lives here.
|
||||
|
||||
func TestMergeCategoryRouting_BothNil(t *testing.T) {
|
||||
func TestSecureRouting_BothNil(t *testing.T) {
|
||||
got := mergeCategoryRouting(nil, nil)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("both nil: got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_DefaultOnly(t *testing.T) {
|
||||
func TestSecureRouting_DefaultOnly(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {"Backend Engineer", "DevOps"},
|
||||
}
|
||||
@@ -209,7 +218,7 @@ func TestMergeCategoryRouting_DefaultOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_WorkspaceOnly(t *testing.T) {
|
||||
func TestSecureRouting_WorkspaceOnly(t *testing.T) {
|
||||
wsRouting := map[string][]string{
|
||||
"ui": {"Frontend Engineer"},
|
||||
}
|
||||
@@ -222,7 +231,7 @@ func TestMergeCategoryRouting_WorkspaceOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_MergeNoOverlap(t *testing.T) {
|
||||
func TestSecureRouting_MergeNoOverlap(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
@@ -235,7 +244,7 @@ func TestMergeCategoryRouting_MergeNoOverlap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_WsOverrideDropsDefault(t *testing.T) {
|
||||
func TestSecureRouting_WsOverrideDropsDefault(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {"Backend Engineer", "DevOps"},
|
||||
}
|
||||
@@ -251,7 +260,34 @@ func TestMergeCategoryRouting_WsOverrideDropsDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyRolesInDefaultSkipped(t *testing.T) {
|
||||
func TestSecureRouting_EmptyListDropsCategory(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
"ui": {"Frontend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {}, // empty list = opt out
|
||||
}
|
||||
got := mergeCategoryRouting(defaultRouting, wsRouting)
|
||||
if _, exists := got["security"]; exists {
|
||||
t.Error("empty ws list should delete the category from output")
|
||||
}
|
||||
if len(got["ui"]) != 1 {
|
||||
t.Errorf("ui should still exist: got %v", got["ui"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureRouting_EmptyKeySkipped(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"": {"Backend Engineer"},
|
||||
}
|
||||
got := mergeCategoryRouting(defaultRouting, nil)
|
||||
if _, exists := got[""]; exists {
|
||||
t.Error("empty key should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureRouting_EmptyRolesInDefaultSkipped(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {},
|
||||
}
|
||||
@@ -261,7 +297,7 @@ func TestMergeCategoryRouting_EmptyRolesInDefaultSkipped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_OriginalMapsUnmodified(t *testing.T) {
|
||||
func TestSecureRouting_OriginalMapsUnmodified(t *testing.T) {
|
||||
defaultRouting := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
|
||||
@@ -952,6 +952,54 @@ type PerWorkspaceUnsatisfied struct {
|
||||
|
||||
// collectPerWorkspaceUnsatisfied recursively walks workspaces and returns
|
||||
// per-workspace RequiredEnv entries that are not covered by (a) a global
|
||||
// secret key or (b) a key present in the workspace's .env file(s) (org root
|
||||
// .env + per-workspace <files_dir>/.env). This complements
|
||||
// collectOrgEnv + loadConfiguredGlobalSecretKeys, which together only
|
||||
// validate global-level RequiredEnv against global_secrets. The .env
|
||||
// lookup mirrors the runtime resolution in createWorkspaceTree so that
|
||||
// the preflight result matches what the container actually receives at
|
||||
// start time.
|
||||
func collectPerWorkspaceUnsatisfied(workspaces []OrgWorkspace, orgBaseDir string, globalSecrets map[string]struct{}) []PerWorkspaceUnsatisfied {
|
||||
var out []PerWorkspaceUnsatisfied
|
||||
var walk func([]OrgWorkspace)
|
||||
walk = func(wsList []OrgWorkspace) {
|
||||
for _, ws := range wsList {
|
||||
// Build the set of keys available to this workspace from .env.
|
||||
// This is the same three-source stack that createWorkspaceTree
|
||||
// injects into the container:
|
||||
// 1. Org root .env (parseEnvFile, no filesDir)
|
||||
// 2. Workspace <files_dir>/.env (if filesDir is set)
|
||||
// 3. Persona bootstrap env (MOLECULE_PERSONA_ROOT/<filesDir>/env)
|
||||
// Items 1+2 are on-disk and testable; item 3 is host-only and
|
||||
// skipped here (persona env does NOT satisfy required_env —
|
||||
// it carries identity tokens, not workspace LLM keys).
|
||||
envFromFiles := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
|
||||
// Convert map[string]string (from .env files) to map[string]struct{}
|
||||
// to match IsSatisfied's signature.
|
||||
envSet := make(map[string]struct{}, len(envFromFiles))
|
||||
for k := range envFromFiles {
|
||||
envSet[k] = struct{}{}
|
||||
}
|
||||
for _, req := range ws.RequiredEnv {
|
||||
if req.IsSatisfied(globalSecrets) {
|
||||
continue // covered by a global secret
|
||||
}
|
||||
if req.IsSatisfied(envSet) {
|
||||
continue // covered by a per-workspace .env file
|
||||
}
|
||||
out = append(out, PerWorkspaceUnsatisfied{
|
||||
Workspace: ws.Name,
|
||||
FilesDir: ws.FilesDir,
|
||||
Unsatisfied: req,
|
||||
})
|
||||
}
|
||||
walk(ws.Children)
|
||||
}
|
||||
}
|
||||
walk(workspaces)
|
||||
return out
|
||||
}
|
||||
|
||||
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
|
||||
|
||||
@@ -17,9 +17,6 @@ import (
|
||||
// when one exists, or the workspace's own ID when it is the org root.
|
||||
// Returns an empty string if the workspace is not found.
|
||||
func resolveOrgID(ctx context.Context, workspaceID string) (string, error) {
|
||||
if db.DB == nil {
|
||||
return "", nil // nil in unit tests
|
||||
}
|
||||
var parentID sql.NullString
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT parent_id FROM workspaces WHERE id = $1`,
|
||||
|
||||
@@ -215,9 +215,6 @@ func TestTarWalk_EmptyDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTarWalk_NestedDirs is defined in plugins_atomic_tar_test.go to avoid
|
||||
// redeclaration. Deeply nested directory walk is tested there.
|
||||
|
||||
// TestTarWalk_DirEntryHasTrailingSlash: directory entries must end with '/'
|
||||
// per tar format; tar.Header.Typeflag '5' (dir) must produce "name/" not "name".
|
||||
func TestTarWalk_DirEntryHasTrailingSlash(t *testing.T) {
|
||||
|
||||
@@ -342,6 +342,11 @@ func TestPluginInstall_InstanceLookupError_Returns503(t *testing.T) {
|
||||
// ---------- dispatch: uninstall ----------
|
||||
|
||||
func TestPluginUninstall_SaaS_DispatchesToEIC(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectExec("DELETE FROM workspace_plugins WHERE workspace_id").
|
||||
WithArgs("ws-1", "browser-automation").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
stubReadPluginManifestViaEIC(t, func(ctx context.Context, instanceID, runtime, pluginName string) ([]byte, error) {
|
||||
return []byte("name: browser-automation\nskills:\n - browse\n"), nil
|
||||
})
|
||||
|
||||
@@ -629,6 +629,9 @@ func TestPluginInstall_RejectsUnknownScheme(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPluginInstall_LocalSourceReachesContainerLookup(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
expectAllowlistAllowAll(mock)
|
||||
|
||||
base := t.TempDir()
|
||||
pluginDir := filepath.Join(base, "demo")
|
||||
_ = os.MkdirAll(pluginDir, 0o755)
|
||||
@@ -955,14 +958,14 @@ func TestLogInstallLimitsOnce(t *testing.T) {
|
||||
|
||||
func TestRegexpEscapeForAwk(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"my-plugin": `my-plugin`,
|
||||
"# Plugin: foo /": `# Plugin: foo \/`,
|
||||
"# Plugin: a.b /": `# Plugin: a\.b \/`,
|
||||
"foo[bar]": `foo\[bar\]`,
|
||||
"a*b+c?": `a\*b\+c\?`,
|
||||
"path|with|pipes": `path\|with\|pipes`,
|
||||
`back\slash`: `back\\slash`,
|
||||
"": ``,
|
||||
"my-plugin": `my-plugin`,
|
||||
"# Plugin: foo /": `# Plugin: foo \/`,
|
||||
"# Plugin: a.b /": `# Plugin: a\.b \/`,
|
||||
"foo[bar]": `foo\[bar\]`,
|
||||
"a*b+c?": `a\*b\+c\?`,
|
||||
"path|with|pipes": `path\|with\|pipes`,
|
||||
`back\slash`: `back\\slash`,
|
||||
"": ``,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := regexpEscapeForAwk(in)
|
||||
@@ -1247,7 +1250,7 @@ func TestPluginDownload_GithubSchemeStreamsTarball(t *testing.T) {
|
||||
scheme: "github",
|
||||
fetchFn: func(_ context.Context, _ string, dst string) (string, error) {
|
||||
files := map[string]string{
|
||||
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
|
||||
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
|
||||
"skills/x/SKILL.md": "---\nname: x\n---\n",
|
||||
"adapters/claude_code.py": "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n",
|
||||
}
|
||||
|
||||
@@ -86,9 +86,6 @@ func recordWorkspacePluginInstall(
|
||||
// pair. Called by the uninstall path so the row doesn't persist with a stale
|
||||
// installed_sha after the plugin has been removed from the container.
|
||||
func deleteWorkspacePluginRow(ctx context.Context, workspaceID, pluginName string) error {
|
||||
if db.DB == nil {
|
||||
return nil // nil in unit tests; no-op since the row is test-only
|
||||
}
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
DELETE FROM workspace_plugins WHERE workspace_id = $1 AND plugin_name = $2
|
||||
`, workspaceID, pluginName)
|
||||
|
||||
@@ -56,10 +56,8 @@ const (
|
||||
// (an externally routable address) is used directly.
|
||||
func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID string) {
|
||||
// Non-blocking send — don't stall the restart cycle.
|
||||
// Run in a tracked async goroutine (goAsync, not bare `go`) so the
|
||||
// caller (runRestartCycle) can proceed to stopForRestart without
|
||||
// waiting, while the test harness can still drain it before swapping
|
||||
// the global db.DB (resolveAgentURLForRestartSignal reads db.DB).
|
||||
// Run in a detached goroutine so the caller (runRestartCycle) can
|
||||
// proceed to stopForRestart without waiting.
|
||||
h.goAsync(func() {
|
||||
signalCtx, cancel := context.WithTimeout(context.Background(), restartSignalTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -271,6 +271,7 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
errToReturn: context.DeadlineExceeded,
|
||||
}
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, hWrapper.WorkspaceHandler)
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
@@ -1,810 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// scheduleCols is the full column set returned by List.
|
||||
var scheduleCols = []string{
|
||||
"id", "workspace_id", "name", "cron_expr", "timezone", "prompt", "enabled",
|
||||
"last_run_at", "next_run_at", "run_count", "last_status", "last_error",
|
||||
"source", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ==================== List ====================
|
||||
|
||||
func TestScheduleHandler_List_EmptyResult(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT .+ FROM workspace_schedules WHERE workspace_id").
|
||||
WithArgs("ws-list-empty").
|
||||
WillReturnRows(sqlmock.NewRows(scheduleCols))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-list-empty"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-list-empty/schedules", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var schedules []interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &schedules); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(schedules) != 0 {
|
||||
t.Errorf("expected empty list, got %d items", len(schedules))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_List_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT .+ FROM workspace_schedules WHERE workspace_id").
|
||||
WithArgs("ws-list-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-list-err"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-list-err/schedules", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
func TestScheduleHandler_Create_MissingCronExpr(t *testing.T) {
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// prompt only — no cron_expr
|
||||
body := []byte(`{"prompt":"do the thing"}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing cron_expr, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_MissingPrompt(t *testing.T) {
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// cron_expr only — no prompt
|
||||
body := []byte(`{"cron_expr":"0 9 * * *"}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing prompt, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_InvalidTimezone(t *testing.T) {
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": "do the thing",
|
||||
"timezone": "Not/A/Timezone",
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid timezone, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if !strings.Contains(resp["error"], "invalid timezone") {
|
||||
t.Errorf("expected 'invalid timezone' error, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_InvalidCron(t *testing.T) {
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"cron_expr": "not-a-cron",
|
||||
"prompt": "do the thing",
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid cron, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if !strings.Contains(resp["error"], "invalid request body") {
|
||||
t.Errorf("expected 'invalid request body' error, got: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_CRLFStripped(t *testing.T) {
|
||||
// Use setupTestDBForQueueTests which sets up QueryMatcherEqual for exact
|
||||
// string matching. The INSERT statement is deterministic enough for that.
|
||||
customSqlmock := setupTestDBForQueueTests(t)
|
||||
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// Prompt with CRLF from a Windows-committed org-template file.
|
||||
// The handler strips \r before inserting so agent doesn't see empty responses.
|
||||
promptWithCRLF := "check\r\ndocs\r\nbefore merge"
|
||||
|
||||
// The handler strips \r → query should receive the LF-only version.
|
||||
customSqlmock.ExpectQuery("INSERT INTO workspace_schedules (workspace_id, name, cron_expr, timezone, prompt, enabled, next_run_at, source) VALUES ($1, $2, $3, $4, $5, $6, $7, 'runtime') RETURNING id").
|
||||
WithArgs("ws-crlf", "", "0 9 * * *", "UTC", "check\ndocs\nbefore merge", true, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-crlf"))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": promptWithCRLF,
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-crlf"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-crlf/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := customSqlmock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_DefaultEnabled(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// enabled field absent — must default to true.
|
||||
mock.ExpectQuery("INSERT INTO workspace_schedules").
|
||||
WithArgs("ws-def-enable", "", "0 9 * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-enable"))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": "do thing",
|
||||
// no "enabled" field
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-def-enable"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-def-enable/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_DefaultTimezone(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// timezone field absent — must default to UTC.
|
||||
mock.ExpectQuery("INSERT INTO workspace_schedules").
|
||||
WithArgs("ws-def-tz", "", "0 9 * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-tz"))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": "do thing",
|
||||
// no "timezone" field
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-def-tz"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-def-tz/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_ExplicitEnabledFalse(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
enabled := false
|
||||
mock.ExpectQuery("INSERT INTO workspace_schedules").
|
||||
WithArgs("ws-dis", "", "0 9 * * *", "UTC", "do thing", enabled, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-dis"))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": "do thing",
|
||||
"enabled": false,
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-dis"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-dis/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("INSERT INTO workspace_schedules").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": "do thing",
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-db-err"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-db-err/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Create_NextRunAtReturned(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("INSERT INTO workspace_schedules").
|
||||
WithArgs("ws-next", "", "0 9 * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-next"))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"cron_expr": "0 9 * * *",
|
||||
"prompt": "do thing",
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-next"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-next/schedules", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "created" {
|
||||
t.Errorf("expected status 'created', got %v", resp["status"])
|
||||
}
|
||||
if _, ok := resp["next_run_at"]; !ok {
|
||||
t.Error("expected next_run_at in response")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Update ====================
|
||||
|
||||
func TestScheduleHandler_Update_PartialRecomputeCron(t *testing.T) {
|
||||
// Uses QueryMatcherEqual so query strings are compared verbatim — no escaping needed.
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
|
||||
WithArgs("sched-recompute-cron", "ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
|
||||
AddRow("0 8 * * *", "UTC"))
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
|
||||
WithArgs("sched-recompute-cron", nil, "0 6 * * *", nil, nil, nil, sqlmock.AnyArg(), "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"cron_expr": "0 6 * * *"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-recompute-cron"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-recompute-cron", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Update_PartialRecomputeTimezone(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
|
||||
WithArgs("sched-recompute-tz", "ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
|
||||
AddRow("0 9 * * *", "UTC"))
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
|
||||
WithArgs("sched-recompute-tz", nil, nil, "America/New_York", nil, nil, sqlmock.AnyArg(), "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"timezone": "America/New_York"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-recompute-tz"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-recompute-tz", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Update_InvalidTimezone(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
|
||||
WithArgs("sched-bad-tz", "ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
|
||||
AddRow("0 9 * * *", "UTC"))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"timezone": "Definitely/Not/Real"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-bad-tz"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-bad-tz", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid timezone, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if !strings.Contains(resp["error"], "invalid timezone") {
|
||||
t.Errorf("expected 'invalid timezone' error, got: %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Update_InvalidCron(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
|
||||
WithArgs("sched-bad-cron", "ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
|
||||
AddRow("0 9 * * *", "UTC"))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"cron_expr": "rubbish"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-bad-cron"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-bad-cron", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid cron, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Update_NotFound(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
|
||||
WithArgs("sched-missing", "renamed", nil, nil, nil, nil, nil, "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // no rows affected
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "renamed"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-missing"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-missing", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for not found, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Update_DBError(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
|
||||
WithArgs("sched-update-err", "updated", nil, nil, nil, nil, nil, "ws-1").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "updated"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-update-err"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-update-err", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Update_PromptCRLFStripped(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// Changing prompt with CRLF → handler strips \r before the UPDATE.
|
||||
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
|
||||
WithArgs("sched-crlf-upd", nil, nil, nil, "fix\nthat", nil, nil, "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"prompt": "fix\r\nthat"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-crlf-upd"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-1/schedules/sched-crlf-upd", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Delete ====================
|
||||
|
||||
func TestScheduleHandler_Delete_Success(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`).
|
||||
WithArgs("sched-del", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-del"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-1/schedules/sched-del", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Delete_NotFound(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
// IDOR guard: row belongs to different workspace → 0 rows affected → 404.
|
||||
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`).
|
||||
WithArgs("sched-idor", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-idor"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-1/schedules/sched-idor", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for not found, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_Delete_DBError(t *testing.T) {
|
||||
mock := setupTestDBForQueueTests(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`).
|
||||
WithArgs("sched-del-err", "ws-1").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-del-err"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-1/schedules/sched-del-err", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RunNow ====================
|
||||
|
||||
func TestScheduleHandler_RunNow_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery(`SELECT prompt FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
|
||||
WithArgs("sched-run-ok", "ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"prompt"}).AddRow("run this prompt"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-run-ok"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules/sched-run-ok/run", nil)
|
||||
|
||||
handler.RunNow(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "fired" {
|
||||
t.Errorf("expected status 'fired', got %v", resp["status"])
|
||||
}
|
||||
if resp["prompt"] != "run this prompt" {
|
||||
t.Errorf("expected prompt 'run this prompt', got %q", resp["prompt"])
|
||||
}
|
||||
if resp["workspace_id"] != "ws-1" {
|
||||
t.Errorf("expected workspace_id 'ws-1', got %q", resp["workspace_id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_RunNow_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery(`SELECT prompt FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
|
||||
WithArgs("sched-run-missing", "ws-1").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-run-missing"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules/sched-run-missing/run", nil)
|
||||
|
||||
handler.RunNow(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for not found, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_RunNow_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery(`SELECT prompt FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
|
||||
WithArgs("sched-run-err", "ws-1").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "scheduleId", Value: "sched-run-err"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/schedules/sched-run-err/run", nil)
|
||||
|
||||
handler.RunNow(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 for DB error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== History ====================
|
||||
|
||||
func TestScheduleHandler_History_EmptyResult(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery(`SELECT created_at, duration_ms, status`).
|
||||
WithArgs("ws-hist-empty", "sched-hist-empty").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"created_at", "duration_ms", "status", "error_detail", "request_body"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-hist-empty"}, {Key: "scheduleId", Value: "sched-hist-empty"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-hist-empty/schedules/sched-hist-empty/history", nil)
|
||||
|
||||
handler.History(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var entries []interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &entries)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected empty history, got %d entries", len(entries))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_History_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
mock.ExpectQuery(`SELECT created_at, duration_ms, status`).
|
||||
WithArgs("ws-hist-err", "sched-hist-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-hist-err"}, {Key: "scheduleId", Value: "sched-hist-err"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-hist-err/schedules/sched-hist-err/history", nil)
|
||||
|
||||
handler.History(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on query error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleHandler_History_MultipleEntries(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewScheduleHandler()
|
||||
|
||||
now := time.Now()
|
||||
cols := []string{"created_at", "duration_ms", "status", "error_detail", "request_body"}
|
||||
mock.ExpectQuery(`SELECT created_at, duration_ms, status`).
|
||||
WithArgs("ws-hist-multi", "sched-hist-multi").
|
||||
WillReturnRows(sqlmock.NewRows(cols).
|
||||
AddRow(now, 1200, "ok", "", `{"schedule_id":"sched-hist-multi"}`).
|
||||
AddRow(now, 3500, "error", "HTTP 502 — upstream timeout", `{"schedule_id":"sched-hist-multi"}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-hist-multi"}, {Key: "scheduleId", Value: "sched-hist-multi"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-hist-multi/schedules/sched-hist-multi/history", nil)
|
||||
|
||||
handler.History(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var entries []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &entries)
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d: %s", len(entries), w.Body.String())
|
||||
}
|
||||
if entries[1]["error_detail"] != "HTTP 502 — upstream timeout" {
|
||||
t.Errorf("expected error_detail on second entry, got: %v", entries[1]["error_detail"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (h *SecretsHandler) List(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("List workspace secrets iteration error: %v", err)
|
||||
log.Printf("List secrets rows.Err: %v", err)
|
||||
}
|
||||
|
||||
// 2. Global secrets not overridden at workspace level
|
||||
@@ -95,7 +95,7 @@ func (h *SecretsHandler) List(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
if err := globalRows.Err(); err != nil {
|
||||
log.Printf("List global secrets iteration error: %v", err)
|
||||
log.Printf("List secrets (global) rows.Err: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, secrets)
|
||||
@@ -181,7 +181,7 @@ func (h *SecretsHandler) Values(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if err := globalRows.Err(); err != nil {
|
||||
log.Printf("secrets.Values: global rows iteration error: %v", err)
|
||||
log.Printf("secrets.Values globalRows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func (h *SecretsHandler) Values(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if err := wsRows.Err(); err != nil {
|
||||
log.Printf("secrets.Values: workspace rows iteration error: %v", err)
|
||||
log.Printf("secrets.Values wsRows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ func (h *SecretsHandler) ListGlobal(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListGlobal iteration error: %v", err)
|
||||
log.Printf("ListGlobal rows.Err: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, secrets)
|
||||
}
|
||||
@@ -416,7 +416,7 @@ func (h *SecretsHandler) restartAllAffectedByGlobalKey(key string) {
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("restartAllAffectedByGlobalKey: iteration error: %v", err)
|
||||
log.Printf("restartAllAffectedByGlobalKey rows.Err: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// template_files_agent_home_stub_test.go — pins the Phase-1 stub
|
||||
// contract for the /agent-home root added by internal#425 RFC.
|
||||
//
|
||||
// Today (pre-Phase-2b), every Files API verb against `?root=/agent-home`
|
||||
// must return HTTP 501 with the canonical pending-message body. The
|
||||
// stub MUST NOT:
|
||||
// 1. Hit the DB (the workspace might not even exist yet from the
|
||||
// canvas's POV — the root selector is testable without one).
|
||||
// 2. Touch the EIC tunnel / Docker / template-dir paths — those
|
||||
// would 500/404/[] depending on the env and confuse the canvas.
|
||||
// 3. Accept writes/deletes that the future docker-exec backend
|
||||
// would reject — fail closed.
|
||||
//
|
||||
// When Phase 2b lands, this file gets replaced by a real
|
||||
// docker-exec dispatch test; the stub-message constant in
|
||||
// templates.go disappears.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestAgentHomeAllowedRoot pins that /agent-home is in the allowedRoots
|
||||
// set. Without this, a future refactor that drops the key would
|
||||
// silently degrade the canvas root selector to a 400 instead of the
|
||||
// stub 501.
|
||||
func TestAgentHomeAllowedRoot(t *testing.T) {
|
||||
if !allowedRoots["/agent-home"] {
|
||||
t.Fatal("/agent-home must be in allowedRoots — RFC #425 contract")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_AllVerbs_Return501 pins the canonical stub
|
||||
// response across all four verbs. Each must:
|
||||
//
|
||||
// - status 501
|
||||
// - body contains the canonical "/agent-home not implemented" prefix
|
||||
// - NOT contain "workspace not found" (proves we short-circuit before
|
||||
// the DB lookup)
|
||||
//
|
||||
// Driven as a table to keep symmetry — adding a fifth verb in the
|
||||
// future means adding one row here.
|
||||
func TestAgentHomeStub_AllVerbs_Return501(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
invoke func(c *gin.Context)
|
||||
}{
|
||||
{
|
||||
name: "ListFiles",
|
||||
method: "GET",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).ListFiles(c) },
|
||||
},
|
||||
{
|
||||
name: "ReadFile",
|
||||
method: "GET",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).ReadFile(c) },
|
||||
},
|
||||
{
|
||||
name: "WriteFile",
|
||||
method: "PUT",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).WriteFile(c) },
|
||||
},
|
||||
{
|
||||
name: "DeleteFile",
|
||||
method: "DELETE",
|
||||
invoke: func(c *gin.Context) { (&TemplatesHandler{}).DeleteFile(c) },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "ws-stub"},
|
||||
// Path param without leading slash so DeleteFile's
|
||||
// filepath.IsAbs guard doesn't 400 before the root
|
||||
// dispatch runs. The List/Read/Write paths strip the
|
||||
// leading slash themselves and accept either form.
|
||||
{Key: "path", Value: "notes.md"},
|
||||
}
|
||||
// WriteFile binds JSON; provide a minimal valid body so the
|
||||
// short-circuit isn't masked by the bind-error path.
|
||||
var body string
|
||||
if tc.method == "PUT" {
|
||||
body = `{"content":"x"}`
|
||||
}
|
||||
c.Request = httptest.NewRequest(
|
||||
tc.method,
|
||||
"/workspaces/ws-stub/files/notes.md?root=/agent-home",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
if body != "" {
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
tc.invoke(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "/agent-home not implemented") {
|
||||
t.Errorf("body should contain canonical stub message; got %s", w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "workspace not found") {
|
||||
t.Errorf("stub leaked through to DB lookup; body=%s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,35 +18,11 @@ import (
|
||||
)
|
||||
|
||||
// allowedRoots are the container paths that the Files API can browse.
|
||||
//
|
||||
// `/agent-home` (added 2026-05-15, internal#425 RFC) is the container's
|
||||
// own $HOME — `/root` for openclaw, `/home/agent` for claude-code/hermes
|
||||
// — browsed via `docker exec` rather than host-side `find`. The
|
||||
// dispatch is stubbed today (returns 501); full implementation lands in
|
||||
// Phase 2b of the RFC. The allowedRoots key is added now so the canvas
|
||||
// can design its root-selector UI against the final shape and the
|
||||
// stub-vs-full transition is server-side only.
|
||||
var allowedRoots = map[string]bool{
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/agent-home": true,
|
||||
}
|
||||
|
||||
// agentHomeStubMessage is the body returned by every Files API verb
|
||||
// when `?root=/agent-home` is requested before Phase 2b lands. Keep the
|
||||
// status code 501 (Not Implemented) — the route exists, the verb is
|
||||
// understood, but the handler is unimplemented. Distinguishes from
|
||||
// 400/404 so a canvas behind a less-current server can render a clean
|
||||
// "feature pending" state instead of a generic error.
|
||||
const agentHomeStubMessage = "/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)"
|
||||
|
||||
// isAgentHomeStubRequest returns true when the request targets the
|
||||
// stubbed /agent-home root. Centralised so every verb in this file
|
||||
// short-circuits with the same response shape.
|
||||
func isAgentHomeStubRequest(rootPath string) bool {
|
||||
return rootPath == "/agent-home"
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
}
|
||||
|
||||
// maxUploadFiles limits the number of files in a single import/replace.
|
||||
@@ -210,11 +186,16 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
model = raw.RuntimeConfig.Model
|
||||
}
|
||||
|
||||
tier := raw.Tier
|
||||
if h.wh != nil && h.wh.IsSaaS() {
|
||||
tier = h.wh.DefaultTier()
|
||||
}
|
||||
|
||||
templates = append(templates, templateSummary{
|
||||
ID: id,
|
||||
Name: raw.Name,
|
||||
Description: raw.Description,
|
||||
Tier: raw.Tier,
|
||||
Tier: tier,
|
||||
Runtime: raw.Runtime,
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
@@ -243,14 +224,7 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
// ?depth= — max depth to recurse (default: 1, max: 5)
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
// /agent-home dispatch is stubbed pre-Phase-2b. Short-circuit before
|
||||
// the DB lookup + EIC dance so a canvas exercising the new root key
|
||||
// gets a clean 501 instead of a half-effort response.
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
subPath := c.DefaultQuery("path", "")
|
||||
@@ -371,6 +345,11 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
if err != nil || path == walkRoot {
|
||||
return nil
|
||||
}
|
||||
// Skip symlinks to prevent path traversal via malicious symlinks
|
||||
// inside the workspace config directory (OFFSEC-010).
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(walkRoot, path)
|
||||
// Enforce depth limit
|
||||
if strings.Count(rel, string(filepath.Separator))+1 > depth {
|
||||
@@ -414,11 +393,7 @@ func (h *TemplatesHandler) ReadFile(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, /agent-home"})
|
||||
return
|
||||
}
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -531,11 +506,7 @@ 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, /agent-home"})
|
||||
return
|
||||
}
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
@@ -612,11 +583,7 @@ func (h *TemplatesHandler) DeleteFile(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, /agent-home"})
|
||||
return
|
||||
}
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": agentHomeStubMessage})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
|
||||
@@ -847,6 +847,58 @@ func TestListFiles_FallbackToHost_WithTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiles_FallbackToHost_SkipsSymlinks(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "test-agent")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte("name: Test Agent\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := filepath.Join(t.TempDir(), "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("do-not-list"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(secret, filepath.Join(tmplDir, "leaked-secret")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil, nil)
|
||||
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-tmpl").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Test Agent", "", ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-tmpl"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-tmpl/files", nil)
|
||||
|
||||
handler.ListFiles(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range resp {
|
||||
if file["path"] == "leaked-secret" {
|
||||
t.Fatalf("symlink should not be listed: %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GET /workspaces/:id/files/*path ====================
|
||||
|
||||
func TestReadFile_PathTraversal(t *testing.T) {
|
||||
@@ -1200,4 +1252,3 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,11 +109,9 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
|
||||
// provisionWorkspaceCP → migration 038). Null instance_id means the
|
||||
// workspace runs as a local Docker container on this tenant.
|
||||
var instanceID string
|
||||
if db.DB != nil {
|
||||
db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID).Scan(&instanceID)
|
||||
}
|
||||
db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID).Scan(&instanceID)
|
||||
|
||||
if instanceID != "" {
|
||||
h.handleRemoteConnect(c, workspaceID, instanceID)
|
||||
@@ -145,7 +143,7 @@ func (h *TerminalHandler) handleLocalConnect(c *gin.Context, workspaceID string)
|
||||
|
||||
// Look up workspace name for manual container naming
|
||||
var wsName string
|
||||
if db.DB != nil && h.docker != nil {
|
||||
if _, err := h.docker.Ping(ctx); err == nil {
|
||||
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||
if wsName != "" {
|
||||
candidates = append(candidates, wsName)
|
||||
|
||||
@@ -340,6 +340,11 @@ func TestSSHCommandCmd_BuildsArgv(t *testing.T) {
|
||||
// a workspace must still be able to access its own terminal. The CanCommunicate
|
||||
// fast-path returns true when callerID == targetID.
|
||||
func TestTerminalConnect_KI005_AllowsOwnTerminal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-alice").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
|
||||
// CanCommunicate fast-path: callerID == targetID → returns true without DB.
|
||||
prev := canCommunicateCheck
|
||||
canCommunicateCheck = func(callerID, targetID string) bool { return callerID == targetID }
|
||||
@@ -367,6 +372,11 @@ func TestTerminalConnect_KI005_AllowsOwnTerminal(t *testing.T) {
|
||||
// skip the CanCommunicate check entirely and fall through to the Docker auth path.
|
||||
// We assert they get the nil-docker 503 instead of 403.
|
||||
func TestTerminalConnect_KI005_SkipsCheckWithoutHeader(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-any").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
|
||||
h := NewTerminalHandler(nil) // nil docker → 503 if reached
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -439,6 +449,9 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-dev").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
|
||||
h := NewTerminalHandler(nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -463,7 +476,10 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
|
||||
// introduced in GH#1885: internal routing uses org tokens which are not in
|
||||
// workspace_auth_tokens, so ValidateToken would always fail for them.
|
||||
func TestKI005_OrgToken_SkipsValidateToken(t *testing.T) {
|
||||
setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
|
||||
mock := setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
WithArgs("ws-target").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
|
||||
prev := canCommunicateCheck
|
||||
canCommunicateCheck = func(callerID, targetID string) bool {
|
||||
// Simulate platform agent → target workspace (same org).
|
||||
@@ -544,4 +560,3 @@ func TestSSHCommandCmd_ConnectTimeoutPresent(t *testing.T) {
|
||||
args)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,20 +10,8 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// validWorkspaceID returns true when id is a syntactically valid UUID.
|
||||
// workspace_id is a `uuid` column; passing a non-UUID (e.g. the canvas
|
||||
// "global" sentinel sent when no node is selected) makes Postgres raise
|
||||
// `invalid input syntax for type uuid`, which previously leaked as an
|
||||
// opaque 500. Reject up front with a clean 400 instead. Mirrors the
|
||||
// uuid.Parse guard already used in handlers/activity.go.
|
||||
func validWorkspaceID(id string) bool {
|
||||
_, err := uuid.Parse(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// TokenHandler exposes user-facing token management for workspaces.
|
||||
// Routes: GET/POST/DELETE /workspaces/:id/tokens (behind WorkspaceAuth).
|
||||
type TokenHandler struct{}
|
||||
@@ -43,10 +31,6 @@ type tokenListItem struct {
|
||||
// never the plaintext or hash).
|
||||
func (h *TokenHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if !validWorkspaceID(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if v := c.Query("limit"); v != "" {
|
||||
@@ -69,7 +53,6 @@ func (h *TokenHandler) List(c *gin.Context) {
|
||||
LIMIT $2 OFFSET $3
|
||||
`, workspaceID, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("tokens: list query failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"})
|
||||
return
|
||||
}
|
||||
@@ -84,9 +67,6 @@ func (h *TokenHandler) List(c *gin.Context) {
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListTokens rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tokens": tokens,
|
||||
@@ -102,10 +82,6 @@ const maxTokensPerWorkspace = 50
|
||||
// exactly once in the response — it cannot be recovered afterwards.
|
||||
func (h *TokenHandler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if !validWorkspaceID(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit: max active tokens per workspace
|
||||
var count int
|
||||
@@ -138,10 +114,6 @@ func (h *TokenHandler) Create(c *gin.Context) {
|
||||
func (h *TokenHandler) Revoke(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
tokenID := c.Param("tokenId")
|
||||
if !validWorkspaceID(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.DB.ExecContext(c.Request.Context(), `
|
||||
UPDATE workspace_auth_tokens
|
||||
|
||||
@@ -41,15 +41,6 @@ import (
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
|
||||
// Workspace IDs are validated as UUIDs up front (tokens.go validWorkspaceID),
|
||||
// so handler tests must pass syntactically valid UUIDs. Fixed values keep
|
||||
// sqlmock WithArgs assertions deterministic.
|
||||
const (
|
||||
wsUUID1 = "11111111-1111-1111-1111-111111111111"
|
||||
wsUUID2 = "22222222-2222-2222-2222-222222222222"
|
||||
wsUUID3 = "33333333-3333-3333-3333-333333333333"
|
||||
)
|
||||
|
||||
// withMockDB swaps `db.DB` for a sqlmock and returns the mock plus a
|
||||
// restore func. Tests use this in place of setupTokenTestDB which
|
||||
// skips on a missing real DB.
|
||||
@@ -90,13 +81,13 @@ func TestTokenHandler_List_HappyPath(t *testing.T) {
|
||||
created := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
||||
last := created.Add(time.Hour)
|
||||
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at\s+FROM workspace_auth_tokens`).
|
||||
WithArgs(wsUUID1, 50, 0).
|
||||
WithArgs("ws-1", 50, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}).
|
||||
AddRow("tok-1", "abc12345", created, last).
|
||||
AddRow("tok-2", "def67890", created, nil))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -130,7 +121,7 @@ func TestTokenHandler_List_EmptyResult(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: wsUUID2}})
|
||||
"/workspaces/ws-2/tokens", gin.Params{{Key: "id", Value: "ws-2"}})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 on empty list, got %d", w.Code)
|
||||
@@ -155,7 +146,7 @@ func TestTokenHandler_List_QueryError(t *testing.T) {
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: wsUUID3}})
|
||||
"/workspaces/ws-3/tokens", gin.Params{{Key: "id", Value: "ws-3"}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("query error must surface as 500, got %d", w.Code)
|
||||
@@ -167,13 +158,13 @@ func TestTokenHandler_List_RespectsLimit(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT id, prefix, created_at, last_used_at`).
|
||||
WithArgs(wsUUID1, 10, 5).
|
||||
WithArgs("ws-1", 10, 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "created_at", "last_used_at"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/tokens?limit=10&offset=5", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsUUID1}}
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
NewTokenHandler().List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -195,7 +186,7 @@ func TestTokenHandler_List_ScanError(t *testing.T) {
|
||||
AddRow("tok-1", "abc", "not-a-timestamp", nil))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().List, "GET",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("scan error must surface as 500, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -210,11 +201,11 @@ func TestTokenHandler_Create_RateLimited(t *testing.T) {
|
||||
|
||||
// Count query returns 50 (== max) → 429.
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs(wsUUID1).
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(50))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("max active tokens should 429, got %d", w.Code)
|
||||
@@ -234,7 +225,7 @@ func TestTokenHandler_Create_IssueFails(t *testing.T) {
|
||||
WillReturnError(errors.New("disk full"))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("IssueToken DB error must 500, got %d", w.Code)
|
||||
@@ -251,7 +242,7 @@ func TestTokenHandler_Create_HappyPath(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Create, "POST",
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
|
||||
"/workspaces/ws-1/tokens", gin.Params{{Key: "id", Value: "ws-1"}})
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -266,7 +257,7 @@ func TestTokenHandler_Create_HappyPath(t *testing.T) {
|
||||
if body.AuthToken == "" {
|
||||
t.Errorf("auth_token must be present and non-empty in response")
|
||||
}
|
||||
if body.WorkspaceID != wsUUID1 {
|
||||
if body.WorkspaceID != "ws-1" {
|
||||
t.Errorf("workspace_id mismatch: %q", body.WorkspaceID)
|
||||
}
|
||||
}
|
||||
@@ -278,12 +269,12 @@ func TestTokenHandler_Revoke_HappyPath(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens\s+SET revoked_at = now\(\)`).
|
||||
WithArgs("tok-1", wsUUID1).
|
||||
WithArgs("tok-1", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-1", gin.Params{
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
})
|
||||
|
||||
@@ -298,12 +289,12 @@ func TestTokenHandler_Revoke_NotFound(t *testing.T) {
|
||||
|
||||
// 0 rows affected → token not found OR already revoked.
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens`).
|
||||
WithArgs("tok-ghost", wsUUID1).
|
||||
WithArgs("tok-ghost", "ws-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-ghost", gin.Params{
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "tokenId", Value: "tok-ghost"},
|
||||
})
|
||||
|
||||
@@ -321,7 +312,7 @@ func TestTokenHandler_Revoke_DBError(t *testing.T) {
|
||||
|
||||
w := makeReq(t, NewTokenHandler().Revoke, "DELETE",
|
||||
"/workspaces/ws-1/tokens/tok-1", gin.Params{
|
||||
{Key: "id", Value: wsUUID1},
|
||||
{Key: "id", Value: "ws-1"},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
})
|
||||
|
||||
@@ -330,59 +321,6 @@ func TestTokenHandler_Revoke_DBError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UUID validation (regression: "global" sentinel 500) ------------
|
||||
|
||||
// The canvas Settings → Workspace Tokens tab sent the literal sentinel
|
||||
// "global" as the workspace id when no node was selected. workspace_id
|
||||
// is a `uuid` column, so the query raised
|
||||
// `invalid input syntax for type uuid: "global"` which leaked as an
|
||||
// opaque 500. List/Create/Revoke now reject any non-UUID id with a
|
||||
// clean 400 before touching the DB. No DB expectation is set on the
|
||||
// mock — a DB hit would fail ExpectationsWereMet, proving short-circuit.
|
||||
func TestTokenHandler_RejectsNonUUIDWorkspaceID(t *testing.T) {
|
||||
h := NewTokenHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(c *gin.Context)
|
||||
method string
|
||||
params gin.Params
|
||||
}{
|
||||
{"List", h.List, "GET", gin.Params{{Key: "id", Value: "global"}}},
|
||||
{"Create", h.Create, "POST", gin.Params{{Key: "id", Value: "global"}}},
|
||||
{"Revoke", h.Revoke, "DELETE", gin.Params{
|
||||
{Key: "id", Value: "global"},
|
||||
{Key: "tokenId", Value: "tok-1"},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
w := makeReq(t, tc.run, tc.method,
|
||||
"/workspaces/global/tokens", tc.params)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s with non-UUID id must 400, got %d: %s",
|
||||
tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body.Error != "invalid workspace id" {
|
||||
t.Errorf("%s: want error=%q, got %q",
|
||||
tc.name, "invalid workspace id", body.Error)
|
||||
}
|
||||
// No query/exec was expected → if the handler hit the DB
|
||||
// this fails, proving the guard short-circuits before SQL.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("%s leaked a DB call past the uuid guard: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time noise removal: the imports list pulls in the sql /
|
||||
// driver packages and the silenced ctx so a future scenario that
|
||||
// needs them doesn't have to re-add the import. Documented here so
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
@@ -168,14 +167,11 @@ func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
|
||||
h := NewTokenHandler()
|
||||
|
||||
// Try to revoke with a different (valid-UUID) workspace ID that does
|
||||
// not own the token — should 404. A valid UUID is required so this
|
||||
// exercises the ownership branch, not the up-front uuid-shape 400.
|
||||
otherWS := uuid.NewString()
|
||||
// Try to revoke with a different workspace ID — should 404
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: otherWS}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+otherWS+"/tokens/"+tokenID, nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "wrong-workspace-id"}, {Key: "tokenId", Value: tokenID}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/wrong/tokens/"+tokenID, nil)
|
||||
h.Revoke(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
|
||||
@@ -107,88 +107,3 @@ func TestTracesList_LangfuseUnreachable(t *testing.T) {
|
||||
t.Errorf("expected empty list when Langfuse unreachable, got %d items", len(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// withLangfuseEnv sets all three required env vars pointing at ts and
|
||||
// arranges a deferred cleanup.
|
||||
func withLangfuseEnv(t *testing.T, ts *httptest.Server) {
|
||||
os.Setenv("LANGFUSE_HOST", ts.URL)
|
||||
os.Setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
|
||||
os.Setenv("LANGFUSE_SECRET_KEY", "sk-test")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("LANGFUSE_HOST")
|
||||
os.Unsetenv("LANGFUSE_PUBLIC_KEY")
|
||||
os.Unsetenv("LANGFUSE_SECRET_KEY")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTracesList_LangfuseSuccess(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewTracesHandler()
|
||||
|
||||
wantBody := `[{"id":"t1","name":"trace-1"},{"id":"t2","name":"trace-2"}]`
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify Basic Auth headers were forwarded
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "pk-test" || pass != "sk-test" {
|
||||
t.Errorf("expected BasicAuth(pk-test,sk-test), got (%q,%q)", user, pass)
|
||||
}
|
||||
// Verify the request was a GET
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(wantBody))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
withLangfuseEnv(t, ts)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-success"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-success/traces", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||
}
|
||||
if got := w.Body.String(); got != wantBody {
|
||||
t.Errorf("body mismatch:\nwant: %s\n got: %s", wantBody, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracesList_LangfuseHTTPError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewTracesHandler()
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":"upstream overloaded"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
withLangfuseEnv(t, ts)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-err"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-err/traces", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
// Non-2xx must be forwarded as-is — not converted to 200
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != `{"error":"upstream overloaded"}` {
|
||||
t.Errorf("expected raw error body, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,21 +74,9 @@ type WorkspaceHandler struct {
|
||||
// memory plugin). main.go sets this to plugin.DeleteNamespace
|
||||
// when MEMORY_PLUGIN_URL is configured.
|
||||
namespaceCleanupFn func(ctx context.Context, workspaceID string)
|
||||
// asyncWG tracks goroutines launched by goAsync so tests can wait
|
||||
// for async DB users (restart, provision) before asserting results.
|
||||
// Matches the pattern from main commit 1c3b4ff3.
|
||||
asyncWG sync.WaitGroup
|
||||
asyncWG sync.WaitGroup
|
||||
}
|
||||
|
||||
// newHandlerHook, when non-nil, is invoked for every WorkspaceHandler
|
||||
// created via NewWorkspaceHandler. It is nil in production (zero cost);
|
||||
// the test harness sets it so setupTestDB can drain every handler's
|
||||
// in-flight async goroutines before swapping the global db.DB. Without
|
||||
// this, a detached restart goroutine (maybeMarkContainerDead ->
|
||||
// goAsync(RestartByID) -> runRestartCycle reads db.DB) races the
|
||||
// db.DB restore in another test's t.Cleanup.
|
||||
var newHandlerHook func(*WorkspaceHandler)
|
||||
|
||||
func (h *WorkspaceHandler) goAsync(fn func()) {
|
||||
h.asyncWG.Add(1)
|
||||
go func() {
|
||||
@@ -117,9 +105,6 @@ func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, plat
|
||||
if p != nil {
|
||||
h.provisioner = p
|
||||
}
|
||||
if newHandlerHook != nil {
|
||||
newHandlerHook(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -176,15 +161,14 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
|
||||
id := uuid.New().String()
|
||||
awarenessNamespace := workspaceAwarenessNamespace(id)
|
||||
if payload.Tier == 0 {
|
||||
// SaaS-aware default. SaaS → T4 (full host access; each
|
||||
// workspace runs on its own sibling EC2 so the tier boundary
|
||||
// is a Docker resource limit on the only container present —
|
||||
// no neighbour to protect from). Self-hosted → T3 (read-write
|
||||
// workspace mount + Docker daemon access, most templates'
|
||||
// baseline). Lower tiers (T1 sandboxed, T2 standard) remain
|
||||
// explicit opt-ins for low-trust agents. Matches the canvas
|
||||
// CreateWorkspaceDialog defaults so the API and the UI agree.
|
||||
if h.IsSaaS() {
|
||||
// SaaS hard gate: every hosted workspace gets its own sibling
|
||||
// EC2 instance, so T4 is the only meaningful runtime boundary.
|
||||
// Do not trust stale clients/templates that still send T1/T2/T3.
|
||||
payload.Tier = 4
|
||||
} else if payload.Tier == 0 {
|
||||
// Self-hosted default remains T3. Lower tiers (T1 sandboxed,
|
||||
// T2 standard) stay explicit opt-ins for low-trust local agents.
|
||||
payload.Tier = h.DefaultTier()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package handlers
|
||||
// workspace_broadcast.go — POST /workspaces/:id/broadcast
|
||||
//
|
||||
// Allows a workspace with broadcast_enabled=true to send a message to every
|
||||
// non-removed agent workspace in the org. The message is:
|
||||
// non-removed agent workspace in the SAME ORG. The message is:
|
||||
//
|
||||
// • Persisted in each recipient's activity_logs (type='broadcast_receive')
|
||||
// so poll-mode agents pick it up via GET /activity.
|
||||
@@ -16,6 +16,11 @@ package handlers
|
||||
// Auth: WorkspaceAuth (the agent triggers this with its own bearer token).
|
||||
// The handler re-validates broadcast_enabled inside the DB lookup to prevent
|
||||
// TOCTOU — the middleware only proved the token is valid, not the ability.
|
||||
//
|
||||
// Org isolation (OFFSEC-015): recipients are scoped to the sender's org using
|
||||
// a recursive CTE that walks the parent_id chain to find the org root. This
|
||||
// prevents a compromised or misconfigured workspace from broadcasting to
|
||||
// workspaces in other tenants' orgs.
|
||||
|
||||
import (
|
||||
"log"
|
||||
@@ -74,11 +79,49 @@ func (h *BroadcastHandler) Broadcast(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect all non-removed agent workspaces (excludes the sender itself).
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT id FROM workspaces WHERE status != 'removed' AND id != $1`,
|
||||
senderID,
|
||||
)
|
||||
// Find the sender's org root by walking the parent_id chain.
|
||||
// Workspaces with parent_id = NULL are org roots; every other workspace
|
||||
// belongs to the org identified by its topmost ancestor.
|
||||
var orgRootID string
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id, id AS root_id
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id, c.root_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.id = c.parent_id
|
||||
)
|
||||
SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
|
||||
`, senderID).Scan(&orgRootID)
|
||||
if err != nil {
|
||||
log.Printf("Broadcast: org root lookup for %s: %v", senderID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Collect all non-removed agent workspaces in the SAME ORG (same root_id),
|
||||
// excluding the sender itself.
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id, id AS root_id
|
||||
FROM workspaces
|
||||
WHERE parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id, c.root_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.parent_id = c.id
|
||||
)
|
||||
SELECT c.id
|
||||
FROM org_chain c
|
||||
WHERE c.root_id = $1
|
||||
AND c.id != $2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM workspaces w
|
||||
WHERE w.id = c.id AND w.status != 'removed'
|
||||
)
|
||||
`, orgRootID, senderID)
|
||||
if err != nil {
|
||||
log.Printf("Broadcast: recipient query failed for %s: %v", senderID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------- Org-scoped recipient query tests (OFFSEC-015) --------
|
||||
|
||||
// TestBroadcast_OrgScopedRecipients verifies that a broadcast from Org-A does
|
||||
// NOT reach workspaces belonging to Org-B. This is the core regression test
|
||||
// for OFFSEC-015: the original query had no org filter, so a workspace in
|
||||
// Org-A could broadcast to every non-removed workspace in the entire DB,
|
||||
// including workspaces owned by other tenants.
|
||||
func TestBroadcast_OrgScopedRecipients(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
// Org-A structure:
|
||||
// org-a-root (parent_id = NULL) ← sender
|
||||
// ├── ws-a-child
|
||||
// Org-B structure:
|
||||
// org-b-root (parent_id = NULL)
|
||||
// └── ws-b-child
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
|
||||
wsAChild := "00000000-0000-0000-0000-000000000002"
|
||||
// ws-b-child is in Org-B (different root); the org-scoped query MUST NOT include it.
|
||||
|
||||
// 1. Sender lookup
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Org-A Root", true))
|
||||
|
||||
// 2. Org root lookup — sender is its own root (parent_id = NULL)
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// 3. Org-scoped recipient query — MUST include org filter so ws-b-child is NOT included.
|
||||
// The query joins on org_chain.root_id = orgRootID, which scopes to Org-A only.
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID). // orgRootID, senderID (EXCLUDED)
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsAChild)) // only Org-A child
|
||||
|
||||
// Activity log inserts
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(wsAChild, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello from org-a"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp["status"] != "sent" {
|
||||
t.Errorf("expected status 'sent', got %v", resp["status"])
|
||||
}
|
||||
// ws-b-child is in a DIFFERENT org — the org-scoped query MUST NOT include it.
|
||||
// If it were included, the mock would have an unmet expectation.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations — cross-org workspace was included in broadcast: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_OrgRootSender verifies that when the sender IS the
|
||||
// org root (parent_id = NULL), broadcasts still reach sibling workspaces.
|
||||
func TestBroadcast_OrgScoped_OrgRootSender(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
|
||||
siblingID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
// Sender is the org root — CTE returns sender's own ID as root
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// Recipients in same org, excluding sender
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello siblings"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_ChildWorkspaceSender verifies that a non-root child
|
||||
// workspace can broadcast to siblings in the same org.
|
||||
func TestBroadcast_OrgScoped_ChildWorkspaceSender(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
orgRootID := "00000000-0000-0000-0000-000000000001"
|
||||
senderID := "00000000-0000-0000-0000-000000000002" // child workspace
|
||||
siblingID := "00000000-0000-0000-0000-000000000003"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Child Agent", true))
|
||||
|
||||
// Org root lookup — walk up to find org-a-root
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(orgRootID))
|
||||
|
||||
// Recipients: same org, excluding sender
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(orgRootID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"child broadcasting"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Non-regression cases --------
|
||||
|
||||
func TestBroadcast_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000099"
|
||||
// UUID is valid, but no workspace row matches
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnError(errors.New("workspace not found"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_Disabled(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"should not send"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "broadcast_disabled" {
|
||||
t.Errorf("expected error 'broadcast_disabled', got %v", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Lone Root", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// No other workspaces in this org
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"hello org"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["delivered"] != float64(0) {
|
||||
t.Errorf("expected delivered=0, got %v", resp["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
body := `{"message":"test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
|
||||
// finding the org root errors, the handler returns 500 instead of proceeding
|
||||
// with an un-scoped query that would broadcast to all orgs.
|
||||
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
// Org root CTE fails
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"should not broadcast"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// The recipient query MUST NOT be called — it would broadcast cross-org
|
||||
// if the org root lookup failed silently.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
|
||||
// from a workspace does not send a broadcast_receive to the sender itself
|
||||
// (the sender logs broadcast_sent, not broadcast_receive).
|
||||
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
peerID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
// Recipient query MUST exclude sender via id != senderID
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerID))
|
||||
|
||||
// Peer receives broadcast_receive
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender logs broadcast_sent (NOT broadcast_receive)
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"no echo to self"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
|
||||
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
|
||||
func TestBroadcast_Truncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
max int
|
||||
expect string
|
||||
}{
|
||||
{"short", 120, "short"}, // under max — no truncation
|
||||
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
|
||||
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
|
||||
// "this is a longer mes" = 20 runes; + "…" = 21 chars
|
||||
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
|
||||
// at-max boundary: 20 chars at max=20 → no truncation
|
||||
{"exactly twenty chars", 20, "exactly twenty chars"},
|
||||
// over max: 11 chars at max=10 → 10 + "…" = 11
|
||||
{"hello world!", 10, "hello worl…"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := broadcastTruncate(tc.msg, tc.max)
|
||||
if result != tc.expect {
|
||||
t.Errorf("broadcastTruncate(%q, %d) = %q; want %q", tc.msg, tc.max, result, tc.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,19 +149,6 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate workspace_dir early so invalid paths are rejected before the
|
||||
// existence check (consistent with name/role/runtime validation above).
|
||||
if wsDir, ok := body["workspace_dir"]; ok {
|
||||
if wsDir != nil {
|
||||
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
|
||||
if err := validateWorkspaceDir(dirStr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Auth is fully enforced at the router layer (WorkspaceAuth middleware, #680).
|
||||
@@ -219,8 +206,15 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
}
|
||||
needsRestart := false
|
||||
if wsDir, ok := body["workspace_dir"]; ok {
|
||||
// ValidateWorkspaceDir was already called above before the existence check;
|
||||
// the UPDATE itself is unconditional.
|
||||
// Allow null to clear workspace_dir
|
||||
if wsDir != nil {
|
||||
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
|
||||
if err := validateWorkspaceDir(dirStr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET workspace_dir = $2, updated_at = now() WHERE id = $1`, id, wsDir); err != nil {
|
||||
log.Printf("Update workspace_dir error for %s: %v", id, err)
|
||||
}
|
||||
|
||||
@@ -187,43 +187,57 @@ func TestState_QueryError(t *testing.T) {
|
||||
// ---------- Update ----------
|
||||
|
||||
func TestUpdate_InvalidUUID(t *testing.T) {
|
||||
err := validateWorkspaceID("not-a-uuid")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid UUID in PATCH path")
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Test"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/not-a-uuid", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_InvalidBody(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader([]byte("not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for malformed JSON, got %d: %s", w.Code, w.Body.String())
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceNotFound(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
body := map[string]interface{}{"name": "New Name"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -231,78 +245,163 @@ func TestUpdate_WorkspaceNotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_NameTooLong(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
longName := make([]byte, 256)
|
||||
for i := range longName {
|
||||
longName[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields(string(longName), "", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for name > 255 chars")
|
||||
body := map[string]interface{}{"name": string(longName)}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for name too long, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoleTooLong(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
longRole := make([]byte, 1001)
|
||||
for i := range longRole {
|
||||
longRole[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields("", string(longRole), "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for role > 1000 chars")
|
||||
body := map[string]interface{}{"role": string(longRole)}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for role too long, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithNewline(t *testing.T) {
|
||||
err := validateWorkspaceFields("Name\nwith newline", "", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for newline in name")
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Name\nwith newline"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for newline in name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithYAMLSpecialChars(t *testing.T) {
|
||||
for _, ch := range "{}[]|>*&!" {
|
||||
err := validateWorkspaceFields("namewith"+string(ch), "", "", "")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for YAML special char %c in name", ch)
|
||||
}
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Name with [brackets]"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for YAML special chars in name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirSystemPath(t *testing.T) {
|
||||
err := validateWorkspaceDir("/etc/my-workspace")
|
||||
if err == nil {
|
||||
t.Error("expected error for /etc/ system path in workspace_dir")
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "/etc/my-workspace"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for system path workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirTraversal(t *testing.T) {
|
||||
err := validateWorkspaceDir("/workspace/../../../etc")
|
||||
if err == nil {
|
||||
t.Error("expected error for traversal in workspace_dir")
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "/workspace/../../../etc"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for traversal in workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirRelativePath(t *testing.T) {
|
||||
err := validateWorkspaceDir("relative/path")
|
||||
if err == nil {
|
||||
t.Error("expected error for relative workspace_dir")
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "relative/path"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for relative workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
|
||||
func TestDelete_InvalidUUID(t *testing.T) {
|
||||
err := validateWorkspaceID("not-a-uuid")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid UUID in DELETE path")
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/not-a-uuid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
r2 := gin.New()
|
||||
r2.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
@@ -312,7 +411,7 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
// No ?confirm=true
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -331,10 +430,12 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDelete_ChildrenCheckQueryError(t *testing.T) {
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
r2 := gin.New()
|
||||
r2.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
@@ -342,7 +443,7 @@ func TestDelete_ChildrenCheckQueryError(t *testing.T) {
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
r2.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"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"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// logProvisionPanic is the deferred recover at the top of every provision
|
||||
@@ -258,7 +259,7 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
// present) wins, matching the existing WorkspaceDir precedence.
|
||||
workspacePath := payload.WorkspaceDir
|
||||
workspaceAccess := payload.WorkspaceAccess
|
||||
if (workspacePath == "" || workspaceAccess == "") && db.DB != nil {
|
||||
if workspacePath == "" || workspaceAccess == "" {
|
||||
var dbDir, dbAccess string
|
||||
if err := db.DB.QueryRow(
|
||||
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none') FROM workspaces WHERE id = $1`,
|
||||
@@ -472,9 +473,10 @@ func configDirName(workspaceID string) string {
|
||||
// runtime means bumping both this list and the Docker image tags.
|
||||
// knownRuntimes is populated from manifest.json at service init (see
|
||||
// runtime_registry.go). The package init order is:
|
||||
// 1. var knownRuntimes = fallbackRuntimes
|
||||
// 2. init() calls initKnownRuntimes() which replaces it if
|
||||
// manifest.json is readable.
|
||||
// 1. var knownRuntimes = fallbackRuntimes
|
||||
// 2. init() calls initKnownRuntimes() which replaces it if
|
||||
// manifest.json is readable.
|
||||
//
|
||||
// The fallback matters for unit tests that don't mount the manifest.
|
||||
//
|
||||
// "external" is a first-class runtime that intentionally does NOT
|
||||
@@ -539,6 +541,9 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
|
||||
// org_import.go; consolidating prevents silent drift.
|
||||
model = models.DefaultModel(runtime)
|
||||
}
|
||||
if runtime == "claude-code" {
|
||||
model = normalizeClaudeCodeModel(model)
|
||||
}
|
||||
|
||||
// Sanitize name/role/model for YAML safety — always double-quote so
|
||||
// a crafted value with a newline or colon can't terminate the scalar
|
||||
@@ -554,6 +559,11 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
|
||||
quoteModel := yamlQuote(model)
|
||||
configYAML := fmt.Sprintf("name: %s\ndescription: %s\nversion: 1.0.0\ntier: %d\nruntime: %s\n",
|
||||
quoteName, quoteRole, payload.Tier, runtime)
|
||||
if runtime == "claude-code" {
|
||||
if providersYAML := h.defaultTemplateProvidersYAML(runtime); providersYAML != "" {
|
||||
configYAML += providersYAML + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Model always at top level — config.py reads raw["model"] for all runtimes.
|
||||
configYAML += fmt.Sprintf("model: %s\n", quoteModel)
|
||||
@@ -563,7 +573,11 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
|
||||
// and preflight already validates that the env vars are present before
|
||||
// the agent loop starts. Hardcoding token names here caused #1028
|
||||
// (expired CLAUDE_CODE_OAUTH_TOKEN baked into config.yaml).
|
||||
configYAML += "runtime_config:\n timeout: 0\n"
|
||||
configYAML += "runtime_config:\n"
|
||||
if runtime == "claude-code" {
|
||||
configYAML += fmt.Sprintf(" model: %s\n", quoteModel)
|
||||
}
|
||||
configYAML += " timeout: 0\n"
|
||||
|
||||
files["config.yaml"] = []byte(configYAML)
|
||||
|
||||
@@ -571,6 +585,60 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
|
||||
return files
|
||||
}
|
||||
|
||||
func normalizeClaudeCodeModel(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if before, after, ok := strings.Cut(model, "/"); ok && before != "" && after != "" {
|
||||
return after
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) defaultTemplateProvidersYAML(runtime string) string {
|
||||
if h.configsDir == "" {
|
||||
return ""
|
||||
}
|
||||
templateName := runtime + "-default"
|
||||
templatePath, err := resolveInsideRoot(h.configsDir, templateName)
|
||||
if err != nil {
|
||||
log.Printf("Provisioner: default template providers skipped for runtime %s: %v", runtime, err)
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(templatePath, "config.yaml"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||
log.Printf("Provisioner: default template providers skipped for runtime %s: invalid YAML: %v", runtime, err)
|
||||
return ""
|
||||
}
|
||||
if len(root.Content) == 0 || root.Content[0].Kind != yaml.MappingNode {
|
||||
return ""
|
||||
}
|
||||
|
||||
mapping := root.Content[0]
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if mapping.Content[i].Value != "providers" {
|
||||
continue
|
||||
}
|
||||
out := yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
Content: []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Value: "providers"},
|
||||
mapping.Content[i+1],
|
||||
},
|
||||
}
|
||||
encoded, err := yaml.Marshal(&out)
|
||||
if err != nil {
|
||||
log.Printf("Provisioner: default template providers skipped for runtime %s: marshal failed: %v", runtime, err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(string(encoded), "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deriveProviderFromModelSlug maps a hermes-agent model slug prefix to
|
||||
// its provider name — a Go translation of the case statement in
|
||||
// workspace-configs-templates/hermes/scripts/derive-provider.sh that we
|
||||
@@ -805,9 +873,6 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
|
||||
envVars[k] = string(decrypted)
|
||||
}
|
||||
}
|
||||
if err := globalRows.Err(); err != nil {
|
||||
log.Printf("Provisioner: global_secrets rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
}
|
||||
wsRows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1`, workspaceID)
|
||||
@@ -826,9 +891,6 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
|
||||
envVars[k] = string(decrypted)
|
||||
}
|
||||
}
|
||||
if err := wsRows.Err(); err != nil {
|
||||
log.Printf("Provisioner: workspace_secrets rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
}
|
||||
return envVars, ""
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func TestProvisionWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
|
||||
rec := &trackingCPProv{startErr: errors.New("simulated CP rejection")}
|
||||
bcast := &concurrentSafeBroadcaster{}
|
||||
h := NewWorkspaceHandler(bcast, nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, h)
|
||||
h.SetCPProvisioner(rec)
|
||||
|
||||
wsID := "ws-routes-to-cp-0123456789abcdef"
|
||||
@@ -595,6 +596,7 @@ func TestRestartWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
|
||||
|
||||
// Mock DB so cpStopWithRetry can run without a real Postgres.
|
||||
mock := setupTestDB(t)
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, h)
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
// provisionWorkspaceCP runs in the goroutine and will hit secrets
|
||||
// SELECTs + UPDATE workspace as failed (we make CP Start return
|
||||
@@ -670,6 +672,7 @@ func TestRestartWorkspaceAuto_RoutesToDockerWhenOnlyDocker(t *testing.T) {
|
||||
|
||||
bcast := &concurrentSafeBroadcaster{}
|
||||
h := NewWorkspaceHandler(bcast, nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, h)
|
||||
stub := &stoppingLocalProv{}
|
||||
h.provisioner = stub
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -260,6 +261,67 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultConfig_ClaudeCodeCopiesProviderRegistry(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
configsDir := t.TempDir()
|
||||
templateDir := filepath.Join(configsDir, "claude-code-default")
|
||||
if err := os.MkdirAll(templateDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir template: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(templateDir, "config.yaml"), []byte(`
|
||||
name: Claude Code Agent
|
||||
runtime: claude-code
|
||||
providers:
|
||||
- name: anthropic-oauth
|
||||
auth_mode: oauth
|
||||
model_aliases: [sonnet]
|
||||
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
|
||||
- name: minimax
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [minimax-]
|
||||
base_url: https://api.minimax.io/anthropic
|
||||
auth_env: [MINIMAX_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||
runtime_config:
|
||||
model: sonnet
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write template: %v", err)
|
||||
}
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
|
||||
|
||||
files := handler.ensureDefaultConfig("ws-code-123", models.CreateWorkspacePayload{
|
||||
Name: "Code Agent",
|
||||
Tier: 4,
|
||||
Runtime: "claude-code",
|
||||
Model: "minimax/MiniMax-M2.7",
|
||||
})
|
||||
|
||||
var parsed struct {
|
||||
Model string `yaml:"model"`
|
||||
Providers []struct {
|
||||
Name string `yaml:"name"`
|
||||
ModelPrefixes []string `yaml:"model_prefixes"`
|
||||
} `yaml:"providers"`
|
||||
RuntimeConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
} `yaml:"runtime_config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(files["config.yaml"], &parsed); err != nil {
|
||||
t.Fatalf("generated YAML invalid: %v\n%s", err, files["config.yaml"])
|
||||
}
|
||||
if parsed.Model != "MiniMax-M2.7" {
|
||||
t.Fatalf("top-level model = %q, want MiniMax-M2.7\n%s", parsed.Model, files["config.yaml"])
|
||||
}
|
||||
if parsed.RuntimeConfig.Model != "MiniMax-M2.7" {
|
||||
t.Fatalf("runtime_config.model = %q, want MiniMax-M2.7\n%s", parsed.RuntimeConfig.Model, files["config.yaml"])
|
||||
}
|
||||
if len(parsed.Providers) != 2 {
|
||||
t.Fatalf("providers len = %d, want 2\n%s", len(parsed.Providers), files["config.yaml"])
|
||||
}
|
||||
if parsed.Providers[1].Name != "minimax" || len(parsed.Providers[1].ModelPrefixes) != 1 || parsed.Providers[1].ModelPrefixes[0] != "minimax-" {
|
||||
t.Fatalf("minimax provider registry not preserved: %+v\n%s", parsed.Providers, files["config.yaml"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultConfig_CustomModel(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
@@ -634,6 +696,11 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
|
||||
// ==================== buildProvisionerConfig ====================
|
||||
|
||||
func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
|
||||
WithArgs("ws-basic").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
tmpDir := t.TempDir()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", tmpDir)
|
||||
@@ -678,6 +745,14 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
|
||||
WithArgs("ws-env").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
|
||||
WithArgs("claude-code").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
|
||||
@@ -237,10 +237,10 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
// the silent-drop bugs PRs #2811/#2824 closed). RestartWorkspaceAuto
|
||||
// enforces CP-FIRST ordering matching the other dispatchers — see
|
||||
// docs/architecture/backends.md.
|
||||
h.goAsync(func() {
|
||||
go func() {
|
||||
h.RestartWorkspaceAutoOpts(context.Background(), id, templatePath, configFiles, payload, resetClaudeSession)
|
||||
})
|
||||
h.goAsync(func() { h.sendRestartContext(id, restartData) })
|
||||
}()
|
||||
go h.sendRestartContext(id, restartData)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "provisioning", "config_dir": configLabel, "reset_session": resetClaudeSession})
|
||||
}
|
||||
@@ -610,9 +610,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
h.provisionWorkspaceAutoSync(workspaceID, "", nil, payload)
|
||||
// sendRestartContext is a one-way notification to the new container; safe
|
||||
// to fire async — the next restart cycle won't depend on it completing.
|
||||
// Tracked via goAsync so the test harness can drain it before the
|
||||
// global db.DB swap (sendRestartContext reads db.DB).
|
||||
h.goAsync(func() { h.sendRestartContext(workspaceID, restartData) })
|
||||
go h.sendRestartContext(workspaceID, restartData)
|
||||
}
|
||||
|
||||
// Pause handles POST /workspaces/:id/pause
|
||||
|
||||
@@ -414,6 +414,44 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
handler.SetCPProvisioner(&trackingCPProv{})
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE workspaces SET url").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_WithSecrets_Persists asserts that secrets in the create
|
||||
// payload are written to workspace_secrets inside the same transaction as the
|
||||
// workspace row, and that the handler returns 201.
|
||||
|
||||
@@ -158,10 +158,6 @@ type cpProvisionRequest struct {
|
||||
Tier int `json:"tier"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
// before including them. Serialised as base64 to avoid JSON escaping.
|
||||
ConfigFiles map[string]string `json:"config_files,omitempty"`
|
||||
}
|
||||
|
||||
@@ -178,28 +174,14 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
// /admin/liveness and other admin-gated platform endpoints (core#831).
|
||||
// p.adminToken is read from os.Getenv("ADMIN_TOKEN") at provisioner creation;
|
||||
// it is also used for CP→platform HTTP auth but those are separate concerns.
|
||||
//
|
||||
// Forensic #145 hardening: tenant workspaces run on EC2 via this path, so
|
||||
// the SCM-write-token denylist (see buildContainerEnv) is enforced here
|
||||
// too. Always build a filtered copy — never pass cfg.EnvVars through
|
||||
// verbatim — so a latent persona-merged GITEA_TOKEN can't reach the
|
||||
// tenant container regardless of whether ADMIN_TOKEN is set.
|
||||
env := make(map[string]string, len(cfg.EnvVars)+1)
|
||||
for k, v := range cfg.EnvVars {
|
||||
if isSCMWriteTokenKey(k) {
|
||||
log.Printf("CPProvisioner.Start: dropped SCM-write credential %q from tenant workspace env (forensic #145 guard)", k)
|
||||
continue
|
||||
}
|
||||
env[k] = v
|
||||
}
|
||||
env := cfg.EnvVars
|
||||
if p.adminToken != "" {
|
||||
env = make(map[string]string, len(cfg.EnvVars)+1)
|
||||
for k, v := range cfg.EnvVars {
|
||||
env[k] = v
|
||||
}
|
||||
env["ADMIN_TOKEN"] = p.adminToken
|
||||
}
|
||||
// Collect template files and generated configs, with OFFSEC-010 guards:
|
||||
// - Rejects symlinks at the template root (prevents bypass via symlink traversal)
|
||||
// - Skips symlinks during WalkDir (prevents /etc/passwd etc. inclusion)
|
||||
// - Validates all paths are relative and non-escaping
|
||||
// - Caps total size at 12 KiB to prevent payload bloat
|
||||
configFiles, err := collectCPConfigFiles(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cp provisioner: collect config files: %w", err)
|
||||
@@ -266,11 +248,6 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
|
||||
const cpConfigFilesMaxBytes = 12 << 10
|
||||
|
||||
// isCPTemplateConfigFile restricts which files from a template directory are
|
||||
// eligible for transport to the control plane. Only config.yaml (the runtime
|
||||
// entrypoint config) and files under prompts/ (system prompts) are needed;
|
||||
// shipping arbitrary files (e.g. adapter.py, Dockerfile) is both unnecessary
|
||||
// and a potential data-exfiltration surface.
|
||||
func isCPTemplateConfigFile(name string) bool {
|
||||
name = filepath.ToSlash(filepath.Clean(name))
|
||||
return name == "config.yaml" || strings.HasPrefix(name, "prompts/")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user