Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84634768d9 | |||
| 406d73ff61 | |||
| 878c74eafe | |||
| f7e2976324 | |||
| 0fc1649a0c | |||
| f1571a04ab | |||
| 2e027df890 | |||
| cb59e658b2 | |||
| 8e3fd8fabe | |||
| a492d11175 | |||
| 90f0399f57 | |||
| 220a04b1b3 | |||
| be9190e57a | |||
| 5c0a48f0f5 | |||
| 50720fb84a | |||
| d594190653 | |||
| c94eca9557 | |||
| 7da843f2fa | |||
| e5521c7675 | |||
| a52110502d | |||
| 69bcc55ad3 | |||
| 36c63798eb | |||
| 43422e0ba9 | |||
| 0ffb29f371 | |||
| 226698239f | |||
| 3c82b39f3d | |||
| 4d32736e25 | |||
| 691d341fbb | |||
| ef42e17224 | |||
| b13c9f94f1 | |||
| 600f88b172 | |||
| df94fd1764 | |||
| 8346b06291 | |||
| b7da21063e | |||
| 2f7b5ad871 | |||
| 213ea06840 | |||
| f07dfa7af6 | |||
| 93f5a4aac3 | |||
| e5d6e45ab1 | |||
| a1cf56cdab | |||
| 436fae8949 | |||
| 2d1a853bf9 | |||
| 5551ef40e3 | |||
| 656176d511 | |||
| 1424af51fa | |||
| 7f0f33739b | |||
| 339d73d9d4 | |||
| 50fe4976e6 | |||
| e05fc4daae | |||
| 6c7f66fa31 | |||
| acf784cd81 | |||
| 543519ed69 | |||
| 010ec0f81b | |||
| bc73f6397a | |||
| e79a842859 | |||
| c3bcf903bd | |||
| 008a19dbdd | |||
| e51dae906f | |||
| f1f7492b66 | |||
| 3161d43cec | |||
| 29349e7af0 | |||
| 78e1025f41 | |||
| af3d98e478 | |||
| 321d051c9f | |||
| 665f0a2405 | |||
| 08ca29fdad | |||
| e6e9731bf3 | |||
| 221b93faec | |||
| 9344d014fb | |||
| 5cc570a18f | |||
| 2be87e66a9 | |||
| a44f98e177 | |||
| ee2d62f679 | |||
| cb22373549 | |||
| 1df028f05b | |||
| b6373e7026 | |||
| bb576c30d2 | |||
| 2357aec4bf | |||
| cace2eb7d3 | |||
| 231fb5ddab | |||
| 01087ddbe7 | |||
| 3112f394eb | |||
| 7fb0da3ed5 | |||
| 805486e36e | |||
| bad6699320 | |||
| 8c3234e4d2 | |||
| 741bb11059 | |||
| 3a82e1f1b1 | |||
| f7183cc0d8 | |||
| 0253cdeb47 | |||
| 65f4ffb0ac | |||
| 6f98ac062e | |||
| 992ccfbd5e | |||
| 086b479dca | |||
| 51284546d2 | |||
| 9b36c9eb7a | |||
| adaaa2a1f8 | |||
| 37739e3dd8 | |||
| 1c76713d71 | |||
| e92468db13 | |||
| be8424c350 | |||
| a7caaa6bd0 | |||
| 3e28bf5943 | |||
| a356bc94f3 | |||
| 9981a5099a | |||
| 07d3dcd988 | |||
| 3ff613e3ad | |||
| 96c37cb098 | |||
| e123d07898 | |||
| 22fbf43580 | |||
| a47307969c | |||
| ff2557d899 | |||
| 119743d0de | |||
| c3806cd890 | |||
| 55e8c2d347 | |||
| 07b465f13d |
@@ -50,9 +50,6 @@ MOLECULE_ENV=development # Environment label (development/
|
||||
# Container/runtime detection
|
||||
# MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false). Triggers A2A proxy to rewrite 127.0.0.1:<port> agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails or to force off.
|
||||
|
||||
# Observability (Awareness)
|
||||
# AWARENESS_URL= # If set, injected into workspace containers along with a deterministic AWARENESS_NAMESPACE derived from workspace ID. Enables the cross-session memory MCP server.
|
||||
|
||||
# GitHub
|
||||
# GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-monorepo). Read inside workspace containers.
|
||||
# GITHUB_TOKEN= # Personal access token / installation token used by agents that clone private repos. Register as a global secret via POST /admin/secrets for propagation to workspace env. Token is used in-URL during clone and then scrubbed from .git/config via `git remote set-url`.
|
||||
|
||||
@@ -21,10 +21,6 @@ from urllib.parse import quote
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
"CI / Platform (Go) (push)",
|
||||
"CI / Canvas (Next.js) (push)",
|
||||
"CI / Shellcheck (E2E scripts) (push)",
|
||||
"CI / Python Lint & Test (push)",
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
|
||||
@@ -128,6 +128,7 @@ fi
|
||||
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
|
||||
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
|
||||
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
|
||||
PR_BASE_SHA=$(jq -r '.base.sha // ""' "$PR_JSON")
|
||||
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
|
||||
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
|
||||
@@ -136,6 +137,10 @@ if [ "$PR_STATE" != "open" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_HEAD_SHA" = "$PR_BASE_SHA" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} has no diff (head == base) — exiting 0 (empty PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable"
|
||||
exit 0
|
||||
|
||||
@@ -104,10 +104,13 @@ if [ "${SOP_REFIRE_DISABLE_RATE_LIMIT:-}" != "1" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Invoke sop-tier-check.sh with the env it expects. Capture exit code.
|
||||
# The canonical script reads tier label, walks approving reviewers, and
|
||||
# evaluates the AND-composition expression — we want the SAME gate, not
|
||||
# a different gate.
|
||||
# 3. Invoke sop-tier-check.sh with the env it expects.
|
||||
# The canonical workflow intentionally fail-opens the job conclusion
|
||||
# (`bash .gitea/scripts/sop-tier-check.sh || true`) while Gitea branch
|
||||
# protection enforces reviewer approvals separately. Keep the refire path
|
||||
# aligned with that workflow status behavior; otherwise /refire-tier-check can
|
||||
# post a hard failure that the canonical pull_request_target workflow would
|
||||
# not publish.
|
||||
#
|
||||
# SOP_REFIRE_TIER_CHECK_SCRIPT env var lets tests substitute a mock —
|
||||
# sop-tier-check.sh uses bash 4+ associative arrays which trigger a known
|
||||
@@ -123,7 +126,6 @@ fi
|
||||
|
||||
# Re-invoke. Pipe stdout/stderr through so the runner log shows the
|
||||
# tier-check decision inline.
|
||||
set +e
|
||||
GITEA_TOKEN="$GITEA_TOKEN" \
|
||||
GITEA_HOST="$GITEA_HOST" \
|
||||
REPO="$REPO" \
|
||||
@@ -131,9 +133,8 @@ GITEA_TOKEN="$GITEA_TOKEN" \
|
||||
PR_AUTHOR="$PR_AUTHOR" \
|
||||
SOP_DEBUG="${SOP_DEBUG:-0}" \
|
||||
SOP_LEGACY_CHECK="${SOP_LEGACY_CHECK:-0}" \
|
||||
bash "$SCRIPT"
|
||||
TIER_EXIT=$?
|
||||
set -e
|
||||
bash "$SCRIPT" || true
|
||||
TIER_EXIT=0
|
||||
debug "sop-tier-check.sh exit=$TIER_EXIT"
|
||||
|
||||
# 4. POST the resulting status.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_workflow(name: str) -> dict:
|
||||
with (ROOT / "workflows" / name).open() as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def test_all_required_uses_dedicated_meta_runner_lane():
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = workflow["jobs"]["all-required"]
|
||||
|
||||
assert all_required["runs-on"] == "ci-meta"
|
||||
assert "needs" not in all_required
|
||||
|
||||
|
||||
def test_all_required_reuses_path_filter_before_polling():
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = workflow["jobs"]["all-required"]
|
||||
rendered = str(all_required)
|
||||
|
||||
assert "--profile ci" in rendered
|
||||
assert ".gitea/scripts/detect-changes.py" in rendered
|
||||
assert "REQUIRE_PLATFORM" in rendered
|
||||
assert "REQUIRE_CANVAS" in rendered
|
||||
assert "REQUIRE_SCRIPTS" in rendered
|
||||
@@ -146,3 +146,10 @@ def test_context_is_terminal_failure_rejects_cancelled_and_skipped():
|
||||
assert prod.context_is_terminal_failure(state) is True
|
||||
for state in ("pending", "missing", "success"):
|
||||
assert prod.context_is_terminal_failure(state) is False
|
||||
|
||||
|
||||
def test_default_required_contexts_delegate_path_gating_to_all_required():
|
||||
assert prod.required_contexts({}) == [
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
# T1: PR open + APPROVED via tier:low → script invokes sop-tier-check
|
||||
# and POSTs status=success.
|
||||
# T2: PR open + missing tier label → sop-tier-check exits non-zero;
|
||||
# refire POSTs status=failure (description mentions failure).
|
||||
# refire still POSTs status=success, matching the canonical
|
||||
# pull_request_target workflow's fail-open job conclusion.
|
||||
# T3: PR open + tier:low but NO approving reviews → sop-tier-check
|
||||
# exits non-zero; refire POSTs status=failure.
|
||||
# exits non-zero; refire still POSTs status=success for the same reason.
|
||||
# T4: PR CLOSED → refire exits 0 with no status POST (no-op on closed).
|
||||
# T5: Rate-limit — recent status update within 30s → refire skips,
|
||||
# no new POST.
|
||||
@@ -32,7 +33,7 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
|
||||
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
|
||||
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
|
||||
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
|
||||
DISPATCH_WORKFLOW="$WORKFLOW_DIR/sop-checklist.yml"
|
||||
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
|
||||
|
||||
PASS=0
|
||||
@@ -88,7 +89,7 @@ assert_file_exists() {
|
||||
echo
|
||||
echo "== existence =="
|
||||
assert_file_exists "workflow file exists" "$WORKFLOW"
|
||||
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
|
||||
assert_file_exists "SSOT dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
|
||||
assert_file_exists "script file exists" "$SCRIPT"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo
|
||||
@@ -133,15 +134,15 @@ else
|
||||
fi
|
||||
|
||||
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
|
||||
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
|
||||
assert_eq "T6e SSOT dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
|
||||
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
|
||||
assert_contains "T6f dispatcher listens on issue_comment" \
|
||||
assert_contains "T6f SSOT dispatcher listens on issue_comment" \
|
||||
"issue_comment" "$DISPATCH_CONTENT"
|
||||
assert_contains "T6g dispatcher handles /qa-recheck" \
|
||||
assert_contains "T6g SSOT dispatcher handles /qa-recheck" \
|
||||
"/qa-recheck" "$DISPATCH_CONTENT"
|
||||
assert_contains "T6h dispatcher handles /security-recheck" \
|
||||
assert_contains "T6h SSOT dispatcher handles /security-recheck" \
|
||||
"/security-recheck" "$DISPATCH_CONTENT"
|
||||
assert_contains "T6i dispatcher handles /refire-tier-check" \
|
||||
assert_contains "T6i SSOT dispatcher handles /refire-tier-check" \
|
||||
"/refire-tier-check" "$DISPATCH_CONTENT"
|
||||
|
||||
# T1-T5 — script behavior against a local Gitea-fixture
|
||||
@@ -245,34 +246,21 @@ assert_contains "T1 POST context is sop-tier-check / tier-check" \
|
||||
'"context": "sop-tier-check / tier-check (pull_request)"' "$POSTED"
|
||||
assert_contains "T1 description names commenter" "test-runner" "$POSTED"
|
||||
|
||||
# T2: missing tier label → tier-check fails → failure status POSTed
|
||||
# T2: missing tier label → tier-check fails internally, but refire status
|
||||
# matches the canonical workflow's fail-open job conclusion.
|
||||
run_scenario "T2_no_tier_label" "fail_no_label"
|
||||
RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
POSTED=$(cat "$FIX_STATE_DIR/posted_statuses.jsonl" 2>/dev/null || true)
|
||||
# tier-check.sh exits 1; refire script forwards that exit, so RC != 0
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
echo " PASS T2 exit code non-zero (got $RC)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL T2 exit code should be non-zero, got 0"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_TESTS="${FAILED_TESTS} T2_rc"
|
||||
fi
|
||||
assert_contains "T2 POSTed state=failure" '"state": "failure"' "$POSTED"
|
||||
assert_eq "T2 exit code 0 (canonical fail-open)" "0" "$RC"
|
||||
assert_contains "T2 POSTed state=success" '"state": "success"' "$POSTED"
|
||||
|
||||
# T3: tier:low present but ZERO approving reviews → failure
|
||||
# T3: tier:low present but ZERO approving reviews → internal tier check fails,
|
||||
# refire status remains aligned with the canonical workflow.
|
||||
run_scenario "T3_no_approvals" "fail_no_approvals"
|
||||
RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
POSTED=$(cat "$FIX_STATE_DIR/posted_statuses.jsonl" 2>/dev/null || true)
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
echo " PASS T3 exit code non-zero (got $RC)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL T3 exit code should be non-zero, got 0"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_TESTS="${FAILED_TESTS} T3_rc"
|
||||
fi
|
||||
assert_contains "T3 POSTed state=failure" '"state": "failure"' "$POSTED"
|
||||
assert_eq "T3 exit code 0 (canonical fail-open)" "0" "$RC"
|
||||
assert_contains "T3 POSTed state=success" '"state": "success"' "$POSTED"
|
||||
|
||||
# T4: closed PR — refire is a no-op (no POST, exit 0)
|
||||
run_scenario "T4_closed" "pass"
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# ci-arm64-advisory — Mac arm64 self-hosted ADVISORY fast-check lane.
|
||||
#
|
||||
# === WHY ===
|
||||
#
|
||||
# The amd64 Gitea runner pool (molecule-runner-1..20) is queue-contended
|
||||
# (internal#418). This lane offloads the *genuinely container-independent*
|
||||
# fast checks (Go build/vet/lint, shellcheck, Python lint) onto the Mac
|
||||
# arm64 self-hosted runner so developers get a fast arm64 signal WITHOUT
|
||||
# adding load to the starved amd64 pool — capability-honestly, as an
|
||||
# additive pilot. Pilot ② of the Mac-CI strategy (CTO-delegated 2026-05-17).
|
||||
#
|
||||
# === NON-NEGOTIABLE SAFETY CONTRACT (the prime directive) ===
|
||||
#
|
||||
# This lane is **ADVISORY ONLY**. It is provably incapable of hanging a
|
||||
# merge. Concretely:
|
||||
#
|
||||
# 1. It is a SEPARATE workflow file. `ci.yml` is byte-for-byte
|
||||
# untouched by this PR. The `CI / all-required` aggregator sentinel
|
||||
# and the five contexts it polls
|
||||
# (`CI / Detect changes|Platform (Go)|Canvas (Next.js)|
|
||||
# Shellcheck (E2E scripts)|Python Lint & Test (pull_request)`)
|
||||
# are unchanged. The canonical required gate stays 100% on the
|
||||
# existing amd64 pool.
|
||||
#
|
||||
# 2. The context this workflow emits is
|
||||
# `ci-arm64-advisory / fast-checks (pull_request)`. That string is
|
||||
# DELIBERATELY NOT present in, and this PR does NOT add it to:
|
||||
# - branch_protections/{main,staging}.status_check_contexts
|
||||
# (DB-verified pb 86/75 = exactly
|
||||
# ["CI / all-required (pull_request)",
|
||||
# "sop-checklist / all-items-acked (pull_request)"])
|
||||
# - audit-force-merge.yml REQUIRED_CHECKS env
|
||||
# - ci.yml `all-required` sentinel's hardcoded `required[]` list
|
||||
# Branch protection therefore never waits on this context. If the
|
||||
# Mac runner is absent / offline / removed, this workflow's status
|
||||
# simply never appears — and because nothing requires it, every
|
||||
# merge proceeds exactly as it does today. There is no path by
|
||||
# which a missing/red arm64 status blocks a merge.
|
||||
#
|
||||
# 3. `continue-on-error: true` on the job — even a genuine arm64-only
|
||||
# failure (toolchain drift, arch-specific test flake) is surfaced
|
||||
# as information, never as a merge blocker, for the duration of
|
||||
# the pilot.
|
||||
#
|
||||
# 4. The job carries a `github.event_name` `if:` gate. Beyond its
|
||||
# functional purpose this also keeps the job OUT of
|
||||
# `ci-required-drift.py:ci_job_names()` (which excludes
|
||||
# `github.event_name`/`github.ref`-gated jobs), so the hourly
|
||||
# ci-required-drift sentinel's F1 ("job not under sentinel needs")
|
||||
# cannot ever flag this advisory job. F2/F3 are untouched because
|
||||
# this context is absent from BP and from REQUIRED_CHECKS.
|
||||
# `lint-bp-context-emit-match` only fails on BP→emitter gaps; an
|
||||
# emitter without a BP context is explicitly informational there.
|
||||
#
|
||||
# === RUNNER TARGETING ===
|
||||
#
|
||||
# The Mac runner is `hongming-pc-runner-1`. The bare `self-hosted`
|
||||
# label is POLLUTED in this Gitea instance: molecule-runner-1..20
|
||||
# (the contended amd64 pool) also advertise `self-hosted`. Targeting
|
||||
# bare `self-hosted` would route back onto the very pool we are trying
|
||||
# to relieve — and onto amd64 hardware. We therefore require an
|
||||
# AND-set of labels that ONLY the Mac satisfies. `macos-self-hosted`
|
||||
# is Mac-exclusive (the amd64 pool does not carry it). Until the
|
||||
# label-install burst (a10862b2) lands `self-hosted`+`macos-self-hosted`
|
||||
# on the Mac, the runner's current unique label `hongming-pc-laptop`
|
||||
# is also listed; AND-semantics over the labels a runner advertises
|
||||
# means a job requiring [self-hosted, macos-self-hosted] can ONLY be
|
||||
# claimed once the Mac advertises both. If neither label set is yet
|
||||
# present on the Mac, the workflow stays queued harmlessly and is
|
||||
# garbage-collected by the normal stale-run reaper — it blocks nothing
|
||||
# (see safety contract point 2).
|
||||
#
|
||||
# === ROLLBACK ===
|
||||
#
|
||||
# Delete this single file (`git rm .gitea/workflows/ci-arm64-advisory.yml`)
|
||||
# and merge. No branch-protection edit, no ci.yml edit, no
|
||||
# REQUIRED_CHECKS edit is required to roll back, because none were made
|
||||
# to roll forward. Zero blast radius either direction.
|
||||
|
||||
name: ci-arm64-advisory
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
|
||||
# Per-ref cancel: a newer commit on the same ref supersedes the older
|
||||
# advisory run. Distinct from ci.yml's `ci-${ref}` group so this lane
|
||||
# never cancels (or is cancelled by) the canonical required CI.
|
||||
concurrency:
|
||||
group: ci-arm64-advisory-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
fast-checks:
|
||||
name: fast-checks
|
||||
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
|
||||
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
|
||||
runs-on: [self-hosted, macos-self-hosted]
|
||||
# ADVISORY: never blocks. See safety contract point 3. mc#774
|
||||
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
|
||||
continue-on-error: true
|
||||
# event_name gate: functional (only meaningful on push/PR) AND keeps
|
||||
# this job out of ci-required-drift.py:ci_job_names() so F1 can never
|
||||
# flag it. See safety contract point 4.
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Provenance — advisory lane, non-gating
|
||||
run: |
|
||||
echo "This is the arm64 ADVISORY fast-check lane."
|
||||
echo "It does NOT gate merges. Canonical required CI is ci.yml"
|
||||
echo "on the amd64 pool. Arch: $(uname -m) on $(uname -s)."
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# ---- Go: build + vet + lint (container-independent: needs only the
|
||||
# Go toolchain; no amd64 ECR image, no docker-in-job). Race-detector
|
||||
# unit-test + coverage gates are deliberately NOT duplicated here —
|
||||
# those stay authoritative on amd64 ci.yml `Platform (Go)`. This lane
|
||||
# is fast-feedback for the compile/vet/lint surface only. ----
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Go build + vet (workspace-server)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
go mod download
|
||||
go build ./cmd/server
|
||||
go vet ./...
|
||||
- name: golangci-lint (workspace-server)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
"$(go env GOPATH)/bin/golangci-lint" run --timeout 3m ./...
|
||||
|
||||
# ---- Shellcheck (container-independent: shellcheck binary only).
|
||||
# Mirrors ci.yml `Shellcheck (E2E scripts)` bulk pass scope. ----
|
||||
- name: Install shellcheck (arm64)
|
||||
run: |
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "shellcheck not preinstalled on this self-hosted runner."
|
||||
echo "Attempting Homebrew install (Mac arm64)."
|
||||
brew install shellcheck || {
|
||||
echo "::warning::shellcheck unavailable on runner; advisory shellcheck skipped."
|
||||
exit 0
|
||||
}
|
||||
fi
|
||||
shellcheck --version
|
||||
- name: Shellcheck tests/e2e + infra/scripts
|
||||
run: |
|
||||
command -v shellcheck >/dev/null 2>&1 || { echo "skip"; exit 0; }
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
# ---- Python lint/compile (container-independent: CPython only).
|
||||
# Lint + import-compile surface; the authoritative pytest + coverage
|
||||
# floors stay on amd64 ci.yml `Python Lint & Test`. ----
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Python byte-compile (workspace)
|
||||
working-directory: workspace
|
||||
run: |
|
||||
python -m pip install --quiet ruff || true
|
||||
python -m compileall -q .
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || echo "::warning::ruff findings (advisory only)"
|
||||
fi
|
||||
|
||||
- name: Advisory summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## arm64 advisory fast-checks complete"
|
||||
echo ""
|
||||
echo "This lane is **advisory** — it does not gate merges."
|
||||
echo "Authoritative required CI remains \`CI / all-required\`"
|
||||
echo "on the amd64 pool (\`ci.yml\`, unchanged by this PR)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
+64
-39
@@ -98,10 +98,10 @@ jobs:
|
||||
--base-ref "$PR_BASE_REF" \
|
||||
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
|
||||
|
||||
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
|
||||
# + per-step gating shape preserves the GitHub-side required-check name
|
||||
# contract (so when this Gitea port becomes a required check in Phase 4,
|
||||
# the name match works on PRs that don't touch workspace-server/).
|
||||
# Platform (Go) — Go build/vet/test/lint + coverage gates. The job always
|
||||
# emits the required context, but expensive steps are path-scoped on every
|
||||
# event so docs/E2E/Canvas-only main pushes do not block deploy on unrelated
|
||||
# Go bootstrap work.
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
@@ -125,29 +125,29 @@ jobs:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform != 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform != 'true' }}
|
||||
working-directory: .
|
||||
run: echo "No workspace-server/** changes on this PR — Platform (Go) gate satisfied without running Go build/test/lint."
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
run: echo "No workspace-server/** changes — Platform (Go) gate satisfied without running Go build/test/lint."
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
run: go mod download
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
run: go vet ./...
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Per-file coverage report
|
||||
# Advisory — lists every source file with its coverage so reviewers
|
||||
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
||||
| sort -n
|
||||
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Check coverage thresholds
|
||||
# Enforces two gates from #1823 Layer 1:
|
||||
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
||||
@@ -282,20 +282,20 @@ jobs:
|
||||
run:
|
||||
working-directory: canvas
|
||||
steps:
|
||||
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.canvas != 'true' }}
|
||||
- if: ${{ needs.changes.outputs.canvas != 'true' }}
|
||||
working-directory: .
|
||||
run: echo "No canvas/** changes on this PR — Canvas (Next.js) gate satisfied without running npm build/test."
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
|
||||
run: echo "No canvas/** changes — Canvas (Next.js) gate satisfied without running npm build/test."
|
||||
- if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
run: npm ci --include=optional --prefer-offline
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
run: npm run build
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
name: Run tests with coverage
|
||||
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
||||
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
||||
@@ -304,7 +304,7 @@ jobs:
|
||||
# tracked in #1815) after the team sees what current coverage is.
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
|
||||
if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# Shellcheck (E2E scripts) — required check, always runs.
|
||||
# Shellcheck (E2E scripts) — required context, path-scoped heavy steps.
|
||||
shellcheck:
|
||||
name: Shellcheck (E2E scripts)
|
||||
needs: changes
|
||||
@@ -326,11 +326,11 @@ jobs:
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.scripts != 'true' }}
|
||||
run: echo "No tests/e2e, scripts, or infra/scripts changes on this PR — Shellcheck gate satisfied without running script checks."
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.scripts != 'true' }}
|
||||
run: echo "No tests/e2e, scripts, or infra/scripts changes — Shellcheck gate satisfied without running script checks."
|
||||
- if: ${{ needs.changes.outputs.scripts == 'true' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.scripts == 'true' }}
|
||||
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
|
||||
@@ -341,16 +341,16 @@ jobs:
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.scripts == 'true' }}
|
||||
name: Lint cleanup-trap hygiene (RFC #2873)
|
||||
run: bash tests/e2e/lint_cleanup_traps.sh
|
||||
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.scripts == 'true' }}
|
||||
name: Run E2E bash unit tests (no live infra)
|
||||
run: |
|
||||
bash tests/e2e/test_model_slug.sh
|
||||
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.scripts == 'true' }}
|
||||
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
|
||||
@@ -360,7 +360,7 @@ jobs:
|
||||
run: |
|
||||
bash scripts/test-promote-tenant-image.sh
|
||||
|
||||
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
|
||||
- if: ${{ needs.changes.outputs.scripts == 'true' }}
|
||||
name: Shellcheck promote-tenant-image script
|
||||
# scripts/ is excluded from the bulk shellcheck pass above (legacy
|
||||
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
|
||||
@@ -476,7 +476,11 @@ jobs:
|
||||
# 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.
|
||||
# or never emit. It runs the same path detector as `changes` and only waits
|
||||
# for path-relevant jobs; Gitea can otherwise leave needs/output-skipped
|
||||
# jobs permanently pending with "Blocked by required conditions". It runs on
|
||||
# the dedicated `ci-meta` lane so the poller does not occupy the same
|
||||
# general runner pool as the jobs it is waiting for.
|
||||
#
|
||||
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
|
||||
# It is an informational main-push reminder, not a PR quality gate. Keeping
|
||||
@@ -484,9 +488,24 @@ jobs:
|
||||
# sentinel before the `always()` guard can emit a branch-protection status.
|
||||
#
|
||||
continue-on-error: false
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-meta
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: check
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
python3 .gitea/scripts/detect-changes.py \
|
||||
--profile ci \
|
||||
--event-name "${{ github.event_name }}" \
|
||||
--pr-base-sha "$PR_BASE_SHA" \
|
||||
--base-ref "$PR_BASE_REF" \
|
||||
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
|
||||
- name: Wait for required CI contexts
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -494,6 +513,9 @@ jobs:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REQUIRE_PLATFORM: ${{ steps.check.outputs.platform }}
|
||||
REQUIRE_CANVAS: ${{ steps.check.outputs.canvas }}
|
||||
REQUIRE_SCRIPTS: ${{ steps.check.outputs.scripts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
@@ -511,11 +533,14 @@ jobs:
|
||||
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})",
|
||||
]
|
||||
if os.environ.get("REQUIRE_PLATFORM") == "true":
|
||||
required.append(f"CI / Platform (Go) ({event})")
|
||||
if os.environ.get("REQUIRE_CANVAS") == "true":
|
||||
required.append(f"CI / Canvas (Next.js) ({event})")
|
||||
if os.environ.get("REQUIRE_SCRIPTS") == "true":
|
||||
required.append(f"CI / Shellcheck (E2E scripts) ({event})")
|
||||
terminal_bad = {"failure", "error"}
|
||||
deadline = time.time() + 40 * 60
|
||||
last_summary = None
|
||||
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# claude-code default: cold-start ~5 min (comparable to langgraph),
|
||||
# but uses MiniMax-M2.7-highspeed via the template's third-party-
|
||||
# but uses MiniMax-M2 via the template's third-party-
|
||||
# Anthropic-compat path (workspace-configs-templates/claude-code-
|
||||
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
|
||||
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
|
||||
@@ -131,9 +131,9 @@ jobs:
|
||||
# on the per-runtime default ("sonnet" → routes to direct
|
||||
# Anthropic, defeats the cost saving). Operators can override
|
||||
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
|
||||
# input if they need to exercise a specific model. M2.7-highspeed
|
||||
# is "Token Plan only" but cheap-per-token and fast.
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
|
||||
# input if they need to exercise a specific model. MiniMax-M2 is the
|
||||
# stable staging MiniMax path used by the full-SaaS smoke.
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2' }}
|
||||
# Bound to 10 min so a stuck provision fails the run instead of
|
||||
# holding up the next cron firing. 15-min default in the script
|
||||
# is for the on-PR full lifecycle where we have more headroom.
|
||||
@@ -145,6 +145,11 @@ jobs:
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
|
||||
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
E2E_AWS_LEAK_CHECK: required
|
||||
E2E_AWS_TERMINATE_LEAKS: '1'
|
||||
# MiniMax key is the canary's PRIMARY auth path. claude-code
|
||||
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
|
||||
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
|
||||
@@ -185,6 +190,12 @@ jobs:
|
||||
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 1
|
||||
fi
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "::error::$var secret missing — EC2 leak verification cannot run"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# LLM-key requirement is per-runtime: claude-code accepts
|
||||
# EITHER MiniMax OR direct-Anthropic (whichever is set first),
|
||||
|
||||
@@ -86,6 +86,7 @@ on:
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- 'tests/e2e/test_peer_visibility_token_mint_staging.sh'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
|
||||
- 'tests/e2e/lib/peer_visibility_assert.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
@@ -98,6 +99,7 @@ on:
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- 'tests/e2e/test_peer_visibility_token_mint_staging.sh'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
|
||||
- 'tests/e2e/lib/peer_visibility_assert.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
@@ -137,8 +139,14 @@ jobs:
|
||||
echo "lib/peer_visibility_assert.sh — bash syntax OK"
|
||||
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
|
||||
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
|
||||
bash -n tests/e2e/test_peer_visibility_token_mint_staging.sh
|
||||
echo "test_peer_visibility_token_mint_staging.sh — bash syntax OK"
|
||||
bash -n tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
echo "test_peer_visibility_mcp_local.sh — bash syntax OK"
|
||||
if rg -n '/admin/workspaces/.*/test-token|test-token' tests/e2e/test_*staging*.sh; then
|
||||
echo "::error::staging E2E must not use dev-only /admin/workspaces/:id/test-token; use production-safe admin token minting instead"
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging fresh-provision MCP list_peers E2E runs on push to"
|
||||
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
|
||||
echo "The LOCAL backend runs in the peer-visibility-local job"
|
||||
|
||||
@@ -49,6 +49,8 @@ on:
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/provisioner/**'
|
||||
- 'tests/e2e/test_staging_full_saas.sh'
|
||||
- 'tests/e2e/lib/aws_leak_check.sh'
|
||||
- 'tests/e2e/test_aws_leak_check.sh'
|
||||
- '.gitea/workflows/e2e-staging-saas.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -59,6 +61,8 @@ on:
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/provisioner/**'
|
||||
- 'tests/e2e/test_staging_full_saas.sh'
|
||||
- 'tests/e2e/lib/aws_leak_check.sh'
|
||||
- 'tests/e2e/test_aws_leak_check.sh'
|
||||
- '.gitea/workflows/e2e-staging-saas.yml'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
@@ -127,6 +131,11 @@ jobs:
|
||||
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
|
||||
# internal#322 — see this PR for the cross-workflow sweep.
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
E2E_AWS_LEAK_CHECK: required
|
||||
E2E_AWS_TERMINATE_LEAKS: '1'
|
||||
# MiniMax is the PRIMARY LLM auth path post-2026-05-04. Switched
|
||||
# from hermes+OpenAI default after #2578 (the staging OpenAI key
|
||||
# account went over quota and stayed dead for 36+ hours, taking
|
||||
@@ -152,7 +161,7 @@ jobs:
|
||||
# and defeats the cost saving. Operators can override via the
|
||||
# workflow_dispatch flow (no input wired here yet — runtime
|
||||
# override is enough for ad-hoc).
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2.7-highspeed' }}
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2' }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
|
||||
@@ -165,6 +174,12 @@ jobs:
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "::error::$var not set — EC2 leak verification cannot run"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
echo "Admin token present ✓"
|
||||
|
||||
- name: Verify LLM key present
|
||||
|
||||
@@ -47,6 +47,11 @@ jobs:
|
||||
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
|
||||
# internal#322 — see this PR for the cross-workflow sweep.
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
E2E_AWS_LEAK_CHECK: required
|
||||
E2E_AWS_TERMINATE_LEAKS: '1'
|
||||
E2E_MODE: smoke
|
||||
E2E_RUNTIME: hermes
|
||||
E2E_RUN_ID: "sanity-${{ github.run_id }}"
|
||||
@@ -61,6 +66,12 @@ jobs:
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN not set"
|
||||
exit 2
|
||||
fi
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "::error::$var not set — EC2 leak verification cannot run"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Inverted assertion: the run MUST fail. If it passes, the
|
||||
# E2E_INTENTIONAL_FAILURE path is broken.
|
||||
|
||||
@@ -25,7 +25,7 @@ permissions:
|
||||
jobs:
|
||||
shellcheck-arm64:
|
||||
name: shellcheck-arm64 (pilot)
|
||||
runs-on: [self-hosted, arm64]
|
||||
runs-on: [self-hosted, arm64-darwin]
|
||||
# NOT a required check; safe to sit pending until Mac runner is up.
|
||||
# If the Mac runner has trouble pulling actions/checkout we fall
|
||||
# back to a plain git clone (see step 'fallback clone').
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install shellcheck (arm64)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -eu
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
@@ -71,11 +72,16 @@ jobs:
|
||||
shellcheck --version | head -2
|
||||
|
||||
- name: Run shellcheck on .gitea/scripts/*.sh
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -eu
|
||||
# Only the scripts we control under .gitea/scripts. Pilot
|
||||
# scope is intentionally narrow — broaden in a follow-up
|
||||
# once the lane is proven.
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "WARN: shellcheck binary not found — skipping (pilot mode)"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t TARGETS < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
|
||||
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
||||
echo "No .sh files found under .gitea/scripts — nothing to check"
|
||||
|
||||
@@ -73,6 +73,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Keep Docker auth/buildx state inside the job temp dir. Publish
|
||||
# runners can inherit a HOME/DOCKER_CONFIG path that is host-owned
|
||||
# and not writable from the job container; docker login otherwise
|
||||
# fails before the image build starts.
|
||||
- name: Prepare writable Docker config
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_CONFIG="$RUNNER_TEMP/docker-config"
|
||||
mkdir -p "$DOCKER_CONFIG/buildx/certs"
|
||||
echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to ECR
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -29,7 +29,8 @@ name: publish-workspace-server-image
|
||||
# Optional staging tenant mirror target:
|
||||
# 004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
|
||||
# Optional secrets: AWS_STAGING_ECR_ACCESS_KEY_ID, AWS_STAGING_ECR_SECRET_ACCESS_KEY
|
||||
# Staging ECR grants the primary SSOT-managed publisher principal repository
|
||||
# policy access, so no persistent staging AWS access keys are required.
|
||||
#
|
||||
# mc#711: Docker daemon not accessible on ubuntu-latest runner (molecule-canonical-1
|
||||
# shows client-only in `docker info` — daemon not running). DinD mount is present but
|
||||
@@ -186,9 +187,10 @@ jobs:
|
||||
--push .
|
||||
|
||||
# Build + push tenant image (Go platform + Next.js canvas in one image).
|
||||
# When staging ECR publisher credentials are configured, push the same
|
||||
# build to the staging account too so fresh staging/E2E tenants can pull
|
||||
# without cross-account ECR permissions.
|
||||
# Push the same build to the staging account too so fresh staging/E2E
|
||||
# tenants can pull without cross-account ECR reads. The staging ECR repo
|
||||
# policy trusts the primary SSOT-managed publisher principal; do not add
|
||||
# separate persistent staging AWS access keys here.
|
||||
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
|
||||
@@ -199,32 +201,22 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_STAGING_ECR_ACCESS_KEY_ID: ${{ secrets.AWS_STAGING_ECR_ACCESS_KEY_ID }}
|
||||
AWS_STAGING_ECR_SECRET_ACCESS_KEY: ${{ secrets.AWS_STAGING_ECR_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
|
||||
STAGING_ECR_REGISTRY="${STAGING_TENANT_IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${STAGING_ECR_REGISTRY}"
|
||||
|
||||
build_tags=(
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
)
|
||||
if [ -n "${AWS_STAGING_ECR_ACCESS_KEY_ID:-}" ] && [ -n "${AWS_STAGING_ECR_SECRET_ACCESS_KEY:-}" ]; then
|
||||
STAGING_ECR_REGISTRY="${STAGING_TENANT_IMAGE_NAME%%/*}"
|
||||
AWS_ACCESS_KEY_ID="${AWS_STAGING_ECR_ACCESS_KEY_ID}" \
|
||||
AWS_SECRET_ACCESS_KEY="${AWS_STAGING_ECR_SECRET_ACCESS_KEY}" \
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${STAGING_ECR_REGISTRY}"
|
||||
build_tags+=(
|
||||
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
)
|
||||
else
|
||||
echo "::notice::Skipping staging ECR tenant push; AWS_STAGING_ECR_ACCESS_KEY_ID/AWS_STAGING_ECR_SECRET_ACCESS_KEY are not configured."
|
||||
fi
|
||||
|
||||
docker buildx build \
|
||||
--file ./workspace-server/Dockerfile.tenant \
|
||||
@@ -242,6 +234,8 @@ jobs:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#774
|
||||
continue-on-error: true
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
|
||||
@@ -81,6 +81,11 @@ jobs:
|
||||
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
|
||||
# internal#322 — see this PR for the cross-workflow sweep.
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
E2E_AWS_LEAK_CHECK: required
|
||||
E2E_AWS_TERMINATE_LEAKS: '1'
|
||||
# MiniMax is the smoke's PRIMARY LLM auth path post-2026-05-04.
|
||||
# Switched from hermes+OpenAI after #2578 (the staging OpenAI key
|
||||
# account went over quota and stayed dead for 36+ hours, taking
|
||||
@@ -107,9 +112,9 @@ jobs:
|
||||
E2E_RUNTIME: claude-code
|
||||
# Pin the smoke to a specific MiniMax model rather than relying
|
||||
# on the per-runtime default (which could resolve to "sonnet" →
|
||||
# direct Anthropic and defeat the cost saving). M2.7-highspeed
|
||||
# is "Token Plan only" but cheap-per-token and fast.
|
||||
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
||||
# direct Anthropic and defeat the cost saving). MiniMax-M2 is the
|
||||
# stable staging MiniMax path used by the full-SaaS smoke.
|
||||
E2E_MODEL_SLUG: MiniMax-M2
|
||||
E2E_RUN_ID: "smoke-${{ github.run_id }}"
|
||||
# Debug-only: when an operator dispatches with keep_on_failure=true,
|
||||
# the smoke script's E2E_KEEP_ORG=1 path skips teardown so the
|
||||
@@ -129,6 +134,12 @@ jobs:
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN not set"
|
||||
exit 2
|
||||
fi
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "::error::$var not set — EC2 leak verification cannot run"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Verify LLM key present
|
||||
run: |
|
||||
|
||||
@@ -40,14 +40,12 @@ name: Sweep stale AWS Secrets Manager secrets
|
||||
# the mostly-orphan tunnels) refuses to nuke past the threshold.
|
||||
|
||||
on:
|
||||
# Disabled as an hourly schedule until the dedicated
|
||||
# AWS_SECRETS_JANITOR_* key exists in the key-management SSOT and is
|
||||
# mirrored into Gitea. Falling back to the molecule-cp app principal is
|
||||
# intentionally not allowed: it lacks account-wide ListSecrets, and
|
||||
# granting that to an application credential would weaken least privilege.
|
||||
#
|
||||
# Keep the manual trigger so operators can validate the workflow immediately
|
||||
# after provisioning the janitor key, then restore the hourly :30 schedule.
|
||||
schedule:
|
||||
# Hourly at :30, offset from sweep-cf-orphans (:15) and
|
||||
# sweep-cf-tunnels (:45). This janitor is intentionally schedule-only
|
||||
# for deletes; manual dispatch is forced to dry-run below because Gitea
|
||||
# 1.22.6 rejects workflow_dispatch.inputs.
|
||||
- cron: '30 * * * *'
|
||||
workflow_dispatch:
|
||||
# Don't let two sweeps race the same AWS account.
|
||||
concurrency:
|
||||
@@ -64,22 +62,24 @@ jobs:
|
||||
sweep:
|
||||
name: Sweep AWS Secrets Manager
|
||||
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
|
||||
# This is a cost/leak janitor. A scheduled failure must be red so
|
||||
# operators know tenant bootstrap secrets may be leaking.
|
||||
# 30 min cap, mirroring the other janitors. AWS DeleteSecret is
|
||||
# fast (~0.3s/call) so even a 100+ backlog drains in seconds
|
||||
# under the 8-way xargs parallelism, but the cap is set generously
|
||||
# to leave headroom for any actual API hang.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
|
||||
# Keep this literal. Gitea/act_runner 1.22.6 can mis-render
|
||||
# secret-backed expressions with `||`, which produced an invalid
|
||||
# Secrets Manager endpoint in the scheduled janitor.
|
||||
AWS_REGION: us-east-2
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_SECRETS_JANITOR_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRETS_JANITOR_SECRET_ACCESS_KEY }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
|
||||
GRACE_HOURS: ${{ github.event.inputs.grace_hours || '24' }}
|
||||
MAX_DELETE_PCT: 50
|
||||
GRACE_HOURS: 24
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -114,17 +114,25 @@ jobs:
|
||||
|
||||
- name: Run sweep
|
||||
if: steps.verify.outputs.skip != 'true'
|
||||
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-tunnels:
|
||||
# - Scheduled: input empty → "false" → --execute (the whole
|
||||
# point of an hourly janitor).
|
||||
# - Manual workflow_dispatch: input default true → dry-run;
|
||||
# operator must flip it to actually delete.
|
||||
# Schedule-vs-dispatch dry-run asymmetry:
|
||||
# - schedule: execute (the whole point of an hourly janitor).
|
||||
# - workflow_dispatch: dry-run. Gitea 1.22.6 rejects
|
||||
# workflow_dispatch.inputs, so there is no safe manual
|
||||
# "flip it to execute" toggle in this workflow.
|
||||
# The script's MAX_DELETE_PCT gate (default 50%) remains the
|
||||
# second line of defense regardless of trigger.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Running in dry-run mode — no deletions"
|
||||
bash scripts/ops/sweep-aws-secrets.sh
|
||||
else
|
||||
echo "Running with --execute — will delete identified orphans"
|
||||
bash scripts/ops/sweep-aws-secrets.sh --execute
|
||||
fi
|
||||
|
||||
- name: Notify on sweep failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "::error::sweep-aws-secrets FAILED — AWS tenant bootstrap secrets may be leaking. Check missing Gitea secrets, staging/prod CP admin tokens, AWS janitor IAM permissions, or the script safety gate."
|
||||
exit 1
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# use this Makefile; CI calls docker compose / go test directly so the
|
||||
# Makefile can evolve without breaking the build.
|
||||
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check
|
||||
|
||||
help: ## Show this help.
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -36,3 +36,23 @@ test: ## Run Go unit tests in workspace-server/.
|
||||
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
|
||||
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
|
||||
bash tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
|
||||
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
|
||||
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
|
||||
# swaggo annotations on the gin handlers. Commit the output. CI runs
|
||||
# `make openapi-spec-check` to assert no drift between annotations and
|
||||
# the committed file — if a PR changes a handler but forgets to
|
||||
# regenerate, CI fails with a diff.
|
||||
openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations.
|
||||
@command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd workspace-server && swag init \
|
||||
--generalInfo cmd/server/main.go \
|
||||
--output docs/openapi \
|
||||
--outputTypes yaml,json \
|
||||
--dir . \
|
||||
--parseDependency=false \
|
||||
--parseInternal=true
|
||||
|
||||
openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file.
|
||||
@git diff --exit-code -- workspace-server/docs/openapi/ \
|
||||
|| (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1)
|
||||
|
||||
@@ -53,7 +53,7 @@ Molecule AI is the most powerful way to govern an AI agent organization in produ
|
||||
It combines the parts that are usually scattered across demos, internal glue code, and framework-specific tooling into one product:
|
||||
|
||||
- one org-native control plane for teams, roles, hierarchy, and lifecycle
|
||||
- one runtime layer that lets **eight** agent runtimes — LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, **Hermes**, **Gemini CLI**, and OpenClaw — run side by side behind one workspace contract
|
||||
- one runtime layer that lets **four** maintained agent runtimes — Claude Code, Codex, **Hermes**, and OpenClaw — run side by side behind one workspace contract
|
||||
- one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries (Memory v2 backed by pgvector for semantic recall)
|
||||
- one operational surface for observing, pausing, restarting, inspecting, and improving live workspaces
|
||||
|
||||
@@ -75,11 +75,11 @@ You do not wire collaboration paths by hand. Hierarchy defines the default commu
|
||||
|
||||
### 3. Runtime choice stops being a dead-end decision
|
||||
|
||||
LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, Hermes, Gemini CLI, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
|
||||
Claude Code, Codex, Hermes, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
|
||||
|
||||
### 4. Memory is treated like infrastructure
|
||||
|
||||
Molecule AI's HMA approach is designed around organizational boundaries, not just “store more context somewhere.” Durable recall, scoped sharing, awareness namespaces, and skill promotion are all part of one coherent system.
|
||||
Molecule AI's HMA approach is designed around organizational boundaries, not just "store more context somewhere." Durable recall, scoped sharing through the v2 memory plugin, and skill promotion are all part of one coherent system.
|
||||
|
||||
### 5. It comes with a real control plane
|
||||
|
||||
@@ -101,7 +101,7 @@ Registry, heartbeats, restart, pause/resume, activity logs, approvals, terminal
|
||||
| **Role-native workspace abstraction** | Your org structure survives model swaps, framework changes, and team expansion |
|
||||
| **Fractal team expansion** | A single specialist can become a managed department without breaking upstream integrations |
|
||||
| **Heterogeneous runtime compatibility** | Different teams can keep their preferred agent architecture while sharing one control plane |
|
||||
| **HMA + awareness namespaces** | Memory sharing follows hierarchy instead of leaking across the whole system |
|
||||
| **HMA + v2 memory plugin** | Memory sharing follows hierarchy instead of leaking across the whole system; one plugin per tenant, namespace-scoped per workspace |
|
||||
| **Skill evolution loop** | Durable successful workflows can graduate from memory into reusable, hot-reloadable skills |
|
||||
| **WebSocket-first operational UX** | The canvas reflects task state, structure changes, and A2A responses in near real time |
|
||||
| **Global secrets with local override** | Centralize provider access, then override only where a workspace needs specialized credentials |
|
||||
@@ -112,13 +112,9 @@ Molecule AI is not trying to replace the frameworks below. It is the system that
|
||||
|
||||
| Runtime / architecture | Status in current repo | Native strength | What Molecule AI adds |
|
||||
|---|---|---|---|
|
||||
| **LangGraph** | Shipping on `main` | Graph control, tool use, Python extensibility | Canvas orchestration, hierarchy routing, A2A, memory scopes, operational lifecycle |
|
||||
| **DeepAgents** | Shipping on `main` | Deeper planning and decomposition | Same workspace contract, team topology, activity stream, restart behavior |
|
||||
| **Claude Code** | Shipping on `main` | Real coding workflows, CLI-native continuity | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane |
|
||||
| **CrewAI** | Shipping on `main` | Role-based crews | Persistent workspace identity, policy consistency, shared canvas and registry |
|
||||
| **AutoGen** | Shipping on `main` | Assistant/tool orchestration | Standardized deployment, hierarchy-aware collaboration, shared ops plane |
|
||||
| **Codex** | Shipping on `main` | OpenAI Codex CLI workflows | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane |
|
||||
| **Hermes 4** | Shipping on `main` | Hybrid reasoning, native tools, json_schema (NousResearch/hermes-agent) | Option B upstream hook, A2A bridge to OpenAI-compat API, multi-provider provider derivation |
|
||||
| **Gemini CLI** | Shipping on `main` | Google Gemini CLI continuity | Workspace lifecycle, A2A, hierarchy-aware collaboration, shared ops plane |
|
||||
| **OpenClaw** | Shipping on `main` | CLI-native runtime with its own session model | Workspace lifecycle, templates, activity logs, topology-aware collaboration |
|
||||
| **NemoClaw** | WIP on `feat/nemoclaw-t4-docker` | NVIDIA-oriented runtime path | Planned to join the same abstraction once merged; not yet part of `main` |
|
||||
|
||||
@@ -133,7 +129,7 @@ Most projects stop at “we added memory.” Molecule AI pushes further:
|
||||
| Flat store or weak namespaces | Hierarchy-aligned `LOCAL`, `TEAM`, `GLOBAL` scopes |
|
||||
| Sharing is easy to overexpose | Sharing is explicit and structure-aware |
|
||||
| Memory and procedure get mixed together | Memory stores durable facts; skills store repeatable procedure |
|
||||
| Every agent can become over-privileged | Workspace awareness namespaces reduce blast radius |
|
||||
| Every agent can become over-privileged | Per-workspace namespaces in the v2 memory plugin reduce blast radius |
|
||||
| UI memory and runtime memory blur together | Separate surfaces for scoped agent memory, key/value workspace memory, and recall |
|
||||
|
||||
### The flywheel
|
||||
@@ -163,7 +159,7 @@ Most agent systems stop at "a smart runtime." Molecule AI pushes further: it giv
|
||||
|
||||
| Core mechanism | Molecule AI module(s) | Why it matters |
|
||||
|---|---|---|
|
||||
| **Durable memory that survives sessions** | `molecule-ai-workspace-runtime/molecule_runtime/builtin_tools/`, `workspace-server/internal/handlers/memories.go` | Memory is not just durable, it is **workspace-scoped** and can route into awareness namespaces tied to the org structure |
|
||||
| **Durable memory that survives sessions** | `molecule-ai-workspace-runtime/molecule_runtime/builtin_tools/`, `workspace-server/internal/handlers/memories.go`, `workspace-server/internal/memory/` (v2 plugin client + namespace resolver) | Memory is not just durable, it is **workspace-scoped** — every write lands in the workspace's own `workspace:<id>` namespace, with `team:<root>` and `org:<root>` available for cross-workspace shares via the platform's namespace ACL when an agent explicitly promotes a memory |
|
||||
| **Cross-session recall** | `workspace-server/internal/handlers/activity.go` (`/workspaces/:id/session-search`) | Recall spans both activity history and memory rows, so the system can search what happened and what was learned without inventing a separate hidden store |
|
||||
| **Skills built from experience** | `molecule-ai-workspace-runtime/molecule_runtime/builtin_tools/memory.py` (`_maybe_log_skill_promotion`) | Promotion from memory into a skill candidate is surfaced as an explicit platform activity, not a silent internal side effect |
|
||||
| **Skill improvement during use** | `molecule-ai-workspace-runtime/molecule_runtime/skill_loader/`, `molecule-ai-workspace-runtime/molecule_runtime/main.py` | Skills hot-reload into the live runtime, so improvements become available on the next A2A task without restarting the workspace |
|
||||
@@ -172,7 +168,7 @@ Most agent systems stop at "a smart runtime." Molecule AI pushes further: it giv
|
||||
### Why this matters in Molecule AI
|
||||
|
||||
1. **The learning loop is org-aware, not just session-aware.**
|
||||
Memory can live at `LOCAL`, `TEAM`, or `GLOBAL` scope, and awareness namespaces give each workspace a durable identity boundary.
|
||||
Memory can live at `LOCAL`, `TEAM`, or `GLOBAL` scope, and the v2 plugin's namespace ACL gives each workspace a durable identity boundary.
|
||||
|
||||
2. **The learning loop is visible to operators.**
|
||||
Promotion events, activity logs, current-task updates, traces, and WebSocket fanout mean self-improvement is part of the control plane, not a hidden black box.
|
||||
@@ -209,9 +205,9 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
### Runtime
|
||||
|
||||
- standalone workspace-template images that install `molecule-ai-workspace-runtime` from the Gitea package registry; thin AMI in production (us-east-2)
|
||||
- adapter-driven execution across **8 runtimes** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw)
|
||||
- adapter-driven execution across **4 maintained runtimes** (Claude Code, Codex, Hermes, OpenClaw)
|
||||
- Agent Card registration
|
||||
- awareness-backed memory integration; **Memory v2 backed by pgvector** for semantic recall
|
||||
- **Memory v2 backed by pgvector** — per-tenant plugin sidecar serving HMA namespaces with FTS + semantic recall
|
||||
- plugin-mounted shared rules/skills
|
||||
- hot-reloadable local skills
|
||||
- coordinator-only delegation path
|
||||
@@ -245,7 +241,7 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
Molecule AI is especially strong when you need to run:
|
||||
|
||||
- AI engineering teams with PM / Dev Lead / QA / Research / Ops roles
|
||||
- mixed runtime organizations where one team prefers LangGraph and another prefers Claude Code
|
||||
- mixed runtime organizations where one team prefers Hermes and another prefers Claude Code
|
||||
- long-lived agent organizations that need memory boundaries and reusable procedures
|
||||
- internal platforms that want to expose agent teams as structured infrastructure, not ad hoc scripts
|
||||
|
||||
@@ -260,9 +256,9 @@ Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080)
|
||||
+------------------------- shows ------------------------> workspaces, teams, tasks, traces, events
|
||||
|
||||
Workspace Runtime (Python ≥3.11, image with adapters)
|
||||
- 8 adapters: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
|
||||
- 4 adapters: Claude Code / Codex / Hermes / OpenClaw
|
||||
- Agent Card + A2A server (typed-SSOT response path, RFC #2967)
|
||||
- heartbeat + activity + awareness-backed memory (Memory v2 — pgvector semantic recall)
|
||||
- heartbeat + activity + Memory v2 (pgvector semantic recall via per-tenant plugin sidecar)
|
||||
- skills + plugins + hot reload
|
||||
|
||||
SaaS Control Plane (molecule-controlplane, private)
|
||||
@@ -328,7 +324,7 @@ Then open `http://localhost:3000`:
|
||||
|
||||
## Current Scope
|
||||
|
||||
The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **eight production adapters** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw), skill lifecycle, and operational surfaces.
|
||||
The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **four maintained production adapters** (Claude Code, Codex, Hermes, OpenClaw), skill lifecycle, and operational surfaces.
|
||||
|
||||
The companion private repo [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) provides the SaaS surface — multi-tenant orchestration on EC2 + Neon + Cloudflare Tunnels, KMS envelope encryption, WorkOS auth, Stripe billing, and a `tenant_resources` audit table with a 30-min reconciler.
|
||||
|
||||
|
||||
+13
-17
@@ -52,7 +52,7 @@ Molecule AI 是目前最强的 AI Agent 组织治理方案之一,用来把 age
|
||||
它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品:
|
||||
|
||||
- 一套组织原生 control plane,管理团队、角色、层级和生命周期
|
||||
- 一套 runtime abstraction,让 **8 个** agent runtime —— LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、**Hermes**、**Gemini CLI**、OpenClaw —— 共用一套 workspace 契约
|
||||
- 一套 runtime abstraction,让 **4 个**维护中的 agent runtime —— Claude Code、Codex、**Hermes**、OpenClaw —— 共用一套 workspace 契约
|
||||
- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系(Memory v2 由 pgvector 支撑语义召回)
|
||||
- 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进
|
||||
|
||||
@@ -74,11 +74,11 @@ Molecule AI 填的就是这个空白。
|
||||
|
||||
### 3. Runtime 选择不再是死路
|
||||
|
||||
LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、Hermes、Gemini CLI、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
|
||||
Claude Code、Codex、Hermes、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
|
||||
|
||||
### 4. Memory 被当成基础设施来做
|
||||
|
||||
Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、awareness namespace、skill promotion,把这些放在一个完整体系里。
|
||||
Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、v2 memory plugin、skill promotion,把这些放在一个完整体系里。
|
||||
|
||||
### 5. 它自带真正的 control plane
|
||||
|
||||
@@ -100,7 +100,7 @@ Registry、heartbeat、restart、pause/resume、activity、approval、terminal
|
||||
| **角色原生 workspace 抽象** | 模型切换、框架切换、团队扩容都不会打碎你的组织结构 |
|
||||
| **分形式团队扩展** | 一个 specialist 可以平滑升级成一个部门,而不影响上游集成 |
|
||||
| **异构 runtime 兼容** | 不同团队可以保留偏好的 agent 架构,但共用一套平台规则 |
|
||||
| **HMA + awareness namespace** | Memory 分享沿组织边界走,而不是全局乱穿透 |
|
||||
| **HMA + v2 memory plugin** | Memory 分享沿组织边界走,而不是全局乱穿透;每个 tenant 一个 plugin,按 workspace namespace 隔离 |
|
||||
| **Skill 演化闭环** | 成功工作流可以从 memory 逐步提升成可热加载的 skill |
|
||||
| **WebSocket-first 运维体验** | Canvas 能即时反映任务状态、结构变更和 A2A 响应 |
|
||||
| **Global secrets + local override** | 统一管理 provider 凭据,只在需要时做 workspace 级覆写 |
|
||||
@@ -111,13 +111,9 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
|
||||
| Runtime / 架构 | 当前仓库状态 | 原生优势 | Molecule AI 额外补上的能力 |
|
||||
|---|---|---|---|
|
||||
| **LangGraph** | `main` 已支持 | 图控制强、工具调用成熟、Python 扩展性好 | Canvas orchestration、层级路由、A2A、memory scope、operational lifecycle |
|
||||
| **DeepAgents** | `main` 已支持 | 规划和任务拆解更强 | 同一套 workspace contract、团队拓扑、activity、restart 行为 |
|
||||
| **Claude Code** | `main` 已支持 | 真实编码工作流、CLI-native continuity | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
|
||||
| **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry |
|
||||
| **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 |
|
||||
| **Codex** | `main` 已支持 | OpenAI Codex CLI 工作流 | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
|
||||
| **Hermes 4** | `main` 已支持 | 混合推理、原生工具调用、json_schema 输出(NousResearch/hermes-agent) | Option B 上游 hook、A2A 桥接 OpenAI 兼容 API、多 provider 自动派生 |
|
||||
| **Gemini CLI** | `main` 已支持 | Google Gemini CLI 持续会话 | workspace 生命周期、A2A、层级感知协作、共享运维平面 |
|
||||
| **OpenClaw** | `main` 已支持 | CLI-native runtime,自有 session 模型 | workspace 生命周期、templates、activity logs、拓扑感知协作 |
|
||||
| **NemoClaw** | `feat/nemoclaw-t4-docker` 分支 WIP | NVIDIA 方向 runtime 路线 | 计划并入同一抽象层,但当前还不是 `main` 已合并能力 |
|
||||
|
||||
@@ -132,7 +128,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
| 扁平 store 或弱命名空间隔离 | 与层级对齐的 `LOCAL`、`TEAM`、`GLOBAL` scope |
|
||||
| 分享很容易越界 | 分享是显式且结构感知的 |
|
||||
| Memory 和 procedure 混成一团 | Memory 存 durable facts,skills 存 repeatable procedure |
|
||||
| 任意 agent 容易过权 | workspace awareness namespace 缩小 blast radius |
|
||||
| 任意 agent 容易过权 | v2 memory plugin 的 per-workspace namespace 缩小 blast radius |
|
||||
| UI memory 和 runtime memory 混在一起 | scoped agent memory、key/value workspace memory、recall surface 分层清晰 |
|
||||
|
||||
### 这套飞轮怎么转
|
||||
@@ -162,7 +158,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
|
||||
| 核心机制 | Molecule AI 对应模块 | 为什么重要 |
|
||||
|---|---|---|
|
||||
| **跨 session 的 durable memory** | `workspace/builtin_tools/memory.py`、`workspace/builtin_tools/awareness_client.py`、`workspace-server/internal/handlers/memories.go` | 不只是持久化,而且是**按 workspace 隔离**的,可进一步路由到和组织结构绑定的 awareness namespace |
|
||||
| **跨 session 的 durable memory** | `workspace/builtin_tools/memory.py`、`workspace-server/internal/handlers/memories.go`、`workspace-server/internal/memory/`(v2 plugin client + namespace resolver)| 不只是持久化,而且是**按 workspace 隔离**的 —— 每次写入都落在 workspace 自己的 `workspace:<id>` namespace 里;当 agent 显式升级到跨 workspace 共享时,可以通过平台 namespace ACL 写到 `team:<root>` 和 `org:<root>` |
|
||||
| **Cross-session recall** | `workspace-server/internal/handlers/activity.go` 中的 `/workspaces/:id/session-search` | Recall 同时覆盖 activity history 和 memory rows,不需要再造一个隐蔽的新存储层 |
|
||||
| **从经验里长出技能** | `workspace/builtin_tools/memory.py` 里的 `_maybe_log_skill_promotion` | 从 memory 到 skill candidate 的提升会被显式记录成平台 activity,而不是默默发生在黑盒里 |
|
||||
| **技能在使用中持续改进** | `workspace/skill_loader/watcher.py`、`workspace/skill_loader/loader.py`、`workspace/main.py` | Skill 改动可以热加载进 live runtime,下一次 A2A 任务就能直接使用,不需要重启 workspace |
|
||||
@@ -171,7 +167,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
### 为什么这在 Molecule AI 里更适合团队级系统
|
||||
|
||||
1. **学习闭环是 org-aware 的,而不只是 session-aware。**
|
||||
Memory 可以按 `LOCAL`、`TEAM`、`GLOBAL` scope 运作,awareness namespace 让每个 workspace 都有清晰的持久边界。
|
||||
Memory 可以按 `LOCAL`、`TEAM`、`GLOBAL` scope 运作,v2 plugin 的 namespace ACL 让每个 workspace 都有清晰的持久边界。
|
||||
|
||||
2. **学习闭环是对运维可见的。**
|
||||
Promotion events、activity logs、current-task updates、traces、WebSocket fanout 让自我进化进入 control plane,而不是藏在黑盒内部。
|
||||
@@ -208,9 +204,9 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
### Runtime
|
||||
|
||||
- 统一 `workspace/` 镜像;生产环境采用 thin AMI(us-east-2)
|
||||
- adapter 驱动执行,覆盖 **8 个 runtime**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)
|
||||
- adapter 驱动执行,覆盖 **4 个维护中的 runtime**(Claude Code、Codex、Hermes、OpenClaw)
|
||||
- Agent Card 注册
|
||||
- awareness-backed memory;**Memory v2 由 pgvector 支撑**语义召回
|
||||
- **Memory v2 由 pgvector 支撑** —— 每个 tenant 一个 plugin sidecar,承载 HMA namespace、FTS 与语义召回
|
||||
- plugin 挂载共享 rules/skills
|
||||
- 本地 skills 热加载
|
||||
- coordinator-only delegation 路径
|
||||
@@ -259,9 +255,9 @@ Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080)
|
||||
+------------------------- 展示 ------------------------> workspaces, teams, tasks, traces, events
|
||||
|
||||
Workspace Runtime (Python ≥3.11,含 adapter 集合的镜像)
|
||||
- 8 个 adapter: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
|
||||
- 4 个 adapter: Claude Code / Codex / Hermes / OpenClaw
|
||||
- Agent Card + A2A server(typed-SSOT 响应路径,RFC #2967)
|
||||
- heartbeat + activity + awareness-backed memory(Memory v2 —— pgvector 语义召回)
|
||||
- heartbeat + activity + Memory v2(pgvector 语义召回,per-tenant plugin sidecar)
|
||||
- skills + plugins + hot reload
|
||||
|
||||
SaaS Control Plane (molecule-controlplane,私有)
|
||||
@@ -321,7 +317,7 @@ npm run dev
|
||||
|
||||
## 当前范围说明
|
||||
|
||||
当前 `main` 已经包含核心平台、Canvas v4(warm-paper 主题)、Memory v2(pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**8 个正式 adapter**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)、skill lifecycle,以及主要运维面。
|
||||
当前 `main` 已经包含核心平台、Canvas v4(warm-paper 主题)、Memory v2(pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**4 个维护中的正式 adapter**(Claude Code、Codex、Hermes、OpenClaw)、skill lifecycle,以及主要运维面。
|
||||
|
||||
配套的私有仓库 [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) 提供 SaaS 层 —— 多租户编排(EC2 + Neon + Cloudflare Tunnels)、KMS 信封加密、WorkOS 鉴权、Stripe 计费,以及 `tenant_resources` 审计表加 30 分钟 reconciler。
|
||||
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "molecule-monorepo-canvas",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
@@ -1110,6 +1111,12 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
|
||||
@@ -33,6 +33,8 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
@@ -68,6 +70,10 @@ export function CreateWorkspaceButton() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
const [displayEnabled, setDisplayEnabled] = useState(false);
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
|
||||
const [displayRootGB, setDisplayRootGB] = useState("80");
|
||||
const [displayResolution, setDisplayResolution] = useState("1920x1080");
|
||||
// Templates fetched from /api/templates — drives the dynamic provider
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
// selected template declares `runtime_config.providers` in its
|
||||
@@ -223,6 +229,10 @@ export function CreateWorkspaceButton() {
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setDisplayEnabled(false);
|
||||
setDisplayInstanceType("t3.xlarge");
|
||||
setDisplayRootGB("80");
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
@@ -264,6 +274,8 @@ export function CreateWorkspaceButton() {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
const [displayWidth, displayHeight] = displayResolution.split("x").map((v) => parseInt(v, 10));
|
||||
const parsedRootGB = parseInt(displayRootGB, 10);
|
||||
|
||||
const createResp = await api.post<{
|
||||
id: string;
|
||||
@@ -280,6 +292,21 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(displayEnabled
|
||||
? {
|
||||
compute: {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
@@ -447,6 +474,73 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExternal && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium text-ink-mid">
|
||||
Container Config
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-ink">Display</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayEnabled}
|
||||
onChange={(e) => setDisplayEnabled(e.target.checked)}
|
||||
aria-label="Enable display"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</label>
|
||||
{displayEnabled && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="display-instance-type" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Instance
|
||||
</label>
|
||||
<select
|
||||
id="display-instance-type"
|
||||
value={displayInstanceType}
|
||||
onChange={(e) => setDisplayInstanceType(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="t3.large">t3.large</option>
|
||||
<option value="t3.xlarge">t3.xlarge</option>
|
||||
<option value="m6i.xlarge">m6i.xlarge</option>
|
||||
<option value="c6i.xlarge">c6i.xlarge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="display-root-gb" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Disk GB
|
||||
</label>
|
||||
<input
|
||||
id="display-root-gb"
|
||||
type="number"
|
||||
min="30"
|
||||
max="500"
|
||||
value={displayRootGB}
|
||||
onChange={(e) => setDisplayRootGB(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="display-resolution" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Resolution
|
||||
</label>
|
||||
<select
|
||||
id="display-resolution"
|
||||
value={displayResolution}
|
||||
onChange={(e) => setDisplayResolution(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="1920x1080">1920 x 1080</option>
|
||||
<option value="1600x900">1600 x 900</option>
|
||||
<option value="1280x720">1280 x 720</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
* "no memories yet".
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { useSocketEvent } from '@/hooks/useSocketEvent';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -246,6 +247,60 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
// Live-refresh on ACTIVITY_LOGGED events that look like memory writes
|
||||
// for this workspace (#1734). Without this, the user sees a stale
|
||||
// empty state after an agent commits — agent says "wrote memory",
|
||||
// panel keeps showing nothing until they hit Refresh.
|
||||
//
|
||||
// What actually broadcasts ACTIVITY_LOGGED on the server today
|
||||
// (workspace-server/internal/handlers/activity.go LogActivity /
|
||||
// LogActivityTx — those are the only emitters):
|
||||
//
|
||||
// - `memory_write_global` — `POST /workspaces/:id/memories` for GLOBAL scope
|
||||
// - `memory_edit_global` — `PATCH /workspaces/:id/memories/:id` for GLOBAL scope
|
||||
// - `memory_delete_global` — `DELETE /workspaces/:id/memories/:id` for GLOBAL scope
|
||||
// - `agent_log` — generic catch-all an agent emits via
|
||||
// `POST /workspaces/:id/activity`
|
||||
//
|
||||
// The MCP-tool path (`commit_memory`, `commit_memory_v2`,
|
||||
// `commit_summary`) does NOT broadcast on the wire today; it inserts
|
||||
// into agent_memories (pre-A1) or calls the v2 plugin (post-A1) and
|
||||
// never round-trips through LogActivity. Server-side follow-up is
|
||||
// tracked in **#1754** — once the MCP handlers emit `memory_write`
|
||||
// via LogActivity, the `agent_log` arm of the filter below can be
|
||||
// dropped. `memory_write` is included pre-emptively so this code
|
||||
// lights up the moment #1754 lands. Until then, `agent_log` catches
|
||||
// MCP commits over-inclusively; the 300ms debounce bounds the
|
||||
// refetch rate. Issue #1734 review finding.
|
||||
//
|
||||
// The 300ms debounce coalesces bursts so a chatty agent (e.g. an
|
||||
// agent in a long task emitting agent_log every few hundred ms)
|
||||
// doesn't hammer /v2/memories on every keystroke-equivalent.
|
||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
}, []);
|
||||
useSocketEvent((msg) => {
|
||||
if (msg.event !== 'ACTIVITY_LOGGED') return;
|
||||
if (msg.workspace_id !== workspaceId) return;
|
||||
const p = (msg.payload || {}) as Record<string, unknown>;
|
||||
const activityType = (p.activity_type as string) || '';
|
||||
switch (activityType) {
|
||||
case 'memory_write':
|
||||
case 'memory_write_global':
|
||||
case 'memory_edit_global':
|
||||
case 'memory_delete_global':
|
||||
case 'agent_log':
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
loadEntries();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── Delete handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { DetailsTab } from "./tabs/DetailsTab";
|
||||
import { SkillsTab } from "./tabs/SkillsTab";
|
||||
import { ChatTab } from "./tabs/ChatTab";
|
||||
import { ConfigTab } from "./tabs/ConfigTab";
|
||||
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
|
||||
import { DisplayTab } from "./tabs/DisplayTab";
|
||||
import { TerminalTab } from "./tabs/TerminalTab";
|
||||
import { FilesTab } from "./tabs/FilesTab";
|
||||
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
|
||||
@@ -31,6 +33,8 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
|
||||
{ id: "details", label: "Details", icon: "◉" },
|
||||
{ id: "skills", label: "Plugins", icon: "✦" },
|
||||
{ id: "terminal", label: "Terminal", icon: "▸" },
|
||||
{ id: "display", label: "Display", icon: "▣" },
|
||||
{ id: "container-config", label: "Container", icon: "▤" },
|
||||
{ id: "config", label: "Config", icon: "⚙" },
|
||||
{ id: "schedule", label: "Schedule", icon: "⏲" },
|
||||
{ id: "channels", label: "Channels", icon: "⇌" },
|
||||
@@ -300,6 +304,8 @@ export function SidePanel() {
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@@ -123,6 +123,46 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.parent_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits compute config by default", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Plain Agent" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toBeUndefined();
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("sends display compute profile when desktop display is enabled", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Desktop Agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Enable display"));
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - handleDeployed fires after 500ms delay
|
||||
*
|
||||
* Uses vi.hoisted + vi.mock to fully isolate the api module, matching
|
||||
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
|
||||
* the pattern established in ApprovalBanner and ScheduleTab tests.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for pure helpers from MemoryInspectorPanel:
|
||||
* isPluginUnavailableError, formatRelativeTime, formatTTL
|
||||
*
|
||||
* These are the three exported non-component functions. The component
|
||||
* itself (MemoryInspectorPanel) requires full API + store mocking and
|
||||
* is exercised by the existing MemoryTab.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
|
||||
|
||||
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
|
||||
|
||||
describe("isPluginUnavailableError", () => {
|
||||
it("returns true when Error message contains MEMORY_PLUGIN_URL", () => {
|
||||
const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured");
|
||||
expect(isPluginUnavailableError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Error containing MEMORY_PLUGIN_URL", () => {
|
||||
expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated error messages", () => {
|
||||
expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isPluginUnavailableError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isPluginUnavailableError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain objects without message", () => {
|
||||
expect(isPluginUnavailableError({ code: 503 })).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => {
|
||||
const lowerErr = new Error("memory_plugin_url missing");
|
||||
const upperErr = new Error("MEMORY_PLUGIN_URL missing");
|
||||
expect(isPluginUnavailableError(lowerErr)).toBe(false);
|
||||
expect(isPluginUnavailableError(upperErr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTTL", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("returns '' for null", () => {
|
||||
expect(formatTTL(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for undefined", () => {
|
||||
expect(formatTTL(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it('returns "expired" when expiresAt is in the past', () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(formatTTL(past)).toBe("expired");
|
||||
});
|
||||
|
||||
it('returns "Xs" for less than a minute', () => {
|
||||
const soon = new Date(Date.now() + 30_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("30s");
|
||||
});
|
||||
|
||||
it('returns "Xm" for less than an hour', () => {
|
||||
const soon = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("5m");
|
||||
});
|
||||
|
||||
it('returns "Xh" for less than a day', () => {
|
||||
const soon = new Date(Date.now() + 3 * 3_600_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("3h");
|
||||
});
|
||||
|
||||
it('returns "Xd" for more than a day', () => {
|
||||
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("2d");
|
||||
});
|
||||
|
||||
it("returns '' for invalid date string", () => {
|
||||
expect(formatTTL("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for empty string", () => {
|
||||
expect(formatTTL("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,17 @@ vi.mock('@/lib/api', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Capture the socket-event handler the panel registers so individual
|
||||
// tests can replay an ACTIVITY_LOGGED message without spinning up a
|
||||
// real WebSocket. One handler at a time is fine — the panel mounts
|
||||
// exactly one useSocketEvent subscriber.
|
||||
let __socketHandler: ((msg: unknown) => void) | null = null;
|
||||
vi.mock('@/hooks/useSocketEvent', () => ({
|
||||
useSocketEvent: (handler: (msg: unknown) => void) => {
|
||||
__socketHandler = handler;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ConfirmDialog', () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
@@ -516,3 +527,156 @@ describe('MemoryInspectorPanel — refresh', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Live-refresh subscription wired in #1734 so the panel reacts to
|
||||
// ACTIVITY_LOGGED events for memory writes on this workspace without
|
||||
// the user clicking Refresh. The hook is mocked at the top of the
|
||||
// file to capture the registered handler in __socketHandler.
|
||||
describe('MemoryInspectorPanel — live refresh on activity', () => {
|
||||
it('refetches memories when ACTIVITY_LOGGED arrives with activity_type=memory_write for the same workspace', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
expect(__socketHandler).toBeTruthy();
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
|
||||
// 300ms debounce inside the panel — advance the fake timer so the
|
||||
// queued refetch fires.
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores ACTIVITY_LOGGED events from other workspaces', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-OTHER',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ignores activity types that are not memory-related', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'a2a_send' },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Server-side emitters confirmed via grep of workspace-server/internal/handlers
|
||||
// are `memory_write_global`, `memory_edit_global`, `memory_delete_global`
|
||||
// (memories.go `LogActivity` calls for GLOBAL-scope writes). Pin each
|
||||
// so a future filter narrow-down can't silently drop one and let the
|
||||
// panel go stale on its actual production trigger.
|
||||
it.each([
|
||||
'memory_write', // pre-emptive: not yet emitted by server, see component comment
|
||||
'memory_write_global', // memories.go:218 (Commit)
|
||||
'memory_edit_global', // memories.go:617 (Update)
|
||||
'memory_delete_global', // memories.go (Delete) — paired with the above two
|
||||
'agent_log', // generic catch-all
|
||||
])('refetches on activity_type=%s', async (activityType) => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: activityType },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('coalesces a burst of memory_write events into one refetch', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Refresh memories'));
|
||||
|
||||
const before = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
__socketHandler!({
|
||||
event: 'ACTIVITY_LOGGED',
|
||||
workspace_id: 'ws-1',
|
||||
payload: { activity_type: 'memory_write' },
|
||||
});
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
|
||||
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null }));
|
||||
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
|
||||
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
|
||||
@@ -74,7 +76,7 @@ import { SidePanel } from "../SidePanel";
|
||||
|
||||
const TABS = [
|
||||
"chat", "activity", "details", "skills", "terminal",
|
||||
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
|
||||
"display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
|
||||
];
|
||||
|
||||
describe("SidePanel — ARIA tablist pattern", () => {
|
||||
@@ -85,10 +87,20 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
|
||||
});
|
||||
|
||||
it("renders exactly 13 tab buttons", () => {
|
||||
it("renders exactly 15 tab buttons", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs.length).toBe(13);
|
||||
expect(tabs.length).toBe(15);
|
||||
});
|
||||
|
||||
it("renders the Display tab", () => {
|
||||
render(<SidePanel />);
|
||||
expect(document.getElementById("tab-display")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Container Config tab", () => {
|
||||
render(<SidePanel />);
|
||||
expect(document.getElementById("tab-container-config")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("active tab (chat) has aria-selected='true'", () => {
|
||||
@@ -99,11 +111,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("all other 12 tabs have aria-selected='false'", () => {
|
||||
it("all other 14 tabs have aria-selected='false'", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const inactive = tabs.filter((t) => t.id !== "tab-chat");
|
||||
expect(inactive.length).toBe(12);
|
||||
expect(inactive.length).toBe(14);
|
||||
for (const tab of inactive) {
|
||||
expect(tab.getAttribute("aria-selected")).toBe("false");
|
||||
}
|
||||
@@ -116,7 +128,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
|
||||
expect(zeros.length).toBe(1);
|
||||
expect(zeros[0].id).toBe("tab-chat");
|
||||
expect(minusOnes.length).toBe(12);
|
||||
expect(minusOnes.length).toBe(14);
|
||||
});
|
||||
|
||||
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
|
||||
|
||||
@@ -369,7 +369,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
onClick={handleCreate}
|
||||
// Was bg-accent-strong hover:bg-accent — accent is the
|
||||
// LIGHTER variant; same AA contrast trap fixed in
|
||||
// ScheduleTab/MemoryTab/OnboardingWizard.
|
||||
// ScheduleTab/OnboardingWizard.
|
||||
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Connect Channel
|
||||
|
||||
@@ -253,7 +253,7 @@ interface RuntimeOption {
|
||||
// its config.yaml under runtime_config.providers. The /templates API
|
||||
// surfaces it (workspace-server templates.go) so canvas stays
|
||||
// adapter-driven: hermes ships ~20 slugs, claude-code ships
|
||||
// ["anthropic"], gemini-cli ships ["gemini"], etc. Empty list →
|
||||
// ["anthropic"], codex ships OpenAI-compatible model ids, etc. Empty list →
|
||||
// canvas falls back to deriving unique vendor prefixes from
|
||||
// models[].id (still adapter-driven, just inferred).
|
||||
providers: string[];
|
||||
@@ -301,16 +301,13 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
// config.yaml` on the container is a separate runtime-internal file,
|
||||
// not this one.
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
||||
const SUPPORTED_RUNTIME_VALUES = new Set(["claude-code", "codex", "openclaw", "hermes"]);
|
||||
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||
{ value: "claude-code", label: "Claude Code", models: [], providers: [] },
|
||||
{ value: "crewai", label: "CrewAI", models: [], providers: [] },
|
||||
{ value: "autogen", label: "AutoGen", models: [], providers: [] },
|
||||
{ value: "deepagents", label: "DeepAgents", models: [], providers: [] },
|
||||
{ value: "codex", label: "Codex", models: [], providers: [] },
|
||||
{ value: "openclaw", label: "OpenClaw", models: [], providers: [] },
|
||||
{ value: "hermes", label: "Hermes", models: [], providers: [] },
|
||||
{ value: "gemini-cli", label: "Gemini CLI", models: [], providers: [] },
|
||||
];
|
||||
|
||||
export function ConfigTab({ workspaceId }: Props) {
|
||||
@@ -499,10 +496,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
.then((rows) => {
|
||||
if (cancelled || !Array.isArray(rows)) return;
|
||||
const byRuntime = new Map<string, RuntimeOption>();
|
||||
byRuntime.set("", { value: "", label: "LangGraph (default)", models: [], providers: [] });
|
||||
for (const r of rows) {
|
||||
const v = (r.runtime || "").trim();
|
||||
if (!v || v === "langgraph") continue;
|
||||
if (!SUPPORTED_RUNTIME_VALUES.has(v)) continue;
|
||||
// Last template wins if two templates share a runtime — rare, and the
|
||||
// one with the richer models list is probably newer.
|
||||
const existing = byRuntime.get(v);
|
||||
@@ -512,7 +508,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
byRuntime.set(v, { value: v, label: r.name || v, models, providers });
|
||||
}
|
||||
}
|
||||
if (byRuntime.size > 1) setRuntimeOptions(Array.from(byRuntime.values()));
|
||||
if (byRuntime.size > 0) setRuntimeOptions(Array.from(byRuntime.values()));
|
||||
})
|
||||
.catch(() => { /* keep fallback */ });
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
type Props = {
|
||||
data: Pick<
|
||||
WorkspaceNodeData,
|
||||
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
|
||||
| "workspaceAccess" | "maxConcurrentTasks"
|
||||
>;
|
||||
};
|
||||
|
||||
export function ContainerConfigTab({ data }: Props) {
|
||||
const runtime = data.runtime || "unknown";
|
||||
const workspaceAccess = formatAccess(data.workspaceAccess);
|
||||
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
|
||||
const mountedPath = "/workspace";
|
||||
const privilegeStatus = "standard";
|
||||
const deliveryMode = data.deliveryMode || "push";
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px]">
|
||||
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
|
||||
<ConfigRow label="Workspace access" value={workspaceAccess} />
|
||||
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
|
||||
<ConfigRow label="Mounted workspace path" value={mountedPath} />
|
||||
<ConfigRow label="Container privileges" value={privilegeStatus} />
|
||||
<ConfigRow label="Delivery mode" value={deliveryMode} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
|
||||
<ReadOnlyAction label="Reset session" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-ink">Status</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 text-[11px]">
|
||||
<ConfigRow label="Container status" value={data.status} />
|
||||
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
|
||||
<ConfigRow label="Mounted path access" value="available" />
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAccess(value: string | null | undefined): string {
|
||||
if (!value) return "none";
|
||||
return value.replace(/_/g, "-");
|
||||
}
|
||||
|
||||
function ConfigRow({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
|
||||
<dt className="text-ink-mid">{label}</dt>
|
||||
<dd className="min-w-0 text-right">
|
||||
<div className="font-mono text-ink break-words">{value}</div>
|
||||
{detail && detail !== value && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyAction({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type RFB from "@novnc/novnc";
|
||||
|
||||
interface DisplayStatus {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface DisplayControlStatus {
|
||||
controller: "none" | "user" | "agent";
|
||||
controlled_by?: string;
|
||||
expires_at?: string;
|
||||
session_url?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function DisplayTab({ workspaceId }: Props) {
|
||||
const [status, setStatus] = useState<DisplayStatus | null>(null);
|
||||
const [control, setControl] = useState<DisplayControlStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [controlError, setControlError] = useState<string | null>(null);
|
||||
const [controlBusy, setControlBusy] = useState(false);
|
||||
const [sessionUrl, setSessionUrl] = useState<string | null>(null);
|
||||
const requestGeneration = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const generation = requestGeneration.current + 1;
|
||||
requestGeneration.current = generation;
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setControl(null);
|
||||
setSessionUrl(null);
|
||||
setError(null);
|
||||
setControlError(null);
|
||||
setControlBusy(false);
|
||||
async function load() {
|
||||
try {
|
||||
const displayStatus = await api.get<DisplayStatus>(`/workspaces/${workspaceId}/display`);
|
||||
if (cancelled || requestGeneration.current !== generation) return;
|
||||
setStatus(displayStatus);
|
||||
if (displayStatus.reason === "display_not_enabled") return;
|
||||
try {
|
||||
const displayControl = await api.get<DisplayControlStatus>(`/workspaces/${workspaceId}/display/control`);
|
||||
if (!cancelled && requestGeneration.current === generation) setControl(displayControl);
|
||||
} catch (err) {
|
||||
if (!cancelled && requestGeneration.current === generation) {
|
||||
setControl(null);
|
||||
setControlError("Display control unavailable");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled && requestGeneration.current === generation) setError("The display status could not be loaded.");
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
const acquireControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/acquire`, {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
setSessionUrl(next.session_url || null);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to take control");
|
||||
try {
|
||||
const latest = await api.get<DisplayControlStatus>(controlPath);
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(latest);
|
||||
} catch {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) setControlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const releaseControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
setSessionUrl(null);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to release control");
|
||||
try {
|
||||
const latest = await api.get<DisplayControlStatus>(controlPath);
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(latest);
|
||||
} catch {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) setControlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
|
||||
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status.available) {
|
||||
const isNotEnabled = status.reason === "display_not_enabled";
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
|
||||
<svg
|
||||
width="72"
|
||||
height="72"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mb-4 text-ink-mid"
|
||||
>
|
||||
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
|
||||
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">
|
||||
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
|
||||
</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
{isNotEnabled
|
||||
? "Recreate this workspace with display enabled to view and take over its desktop."
|
||||
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
|
||||
</p>
|
||||
{!isNotEnabled && (
|
||||
<>
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
|
||||
<dt className="text-ink-mid">Mode</dt>
|
||||
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
|
||||
<dt className="text-ink-mid">Status</dt>
|
||||
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
|
||||
</dl>
|
||||
<div className="mt-5 w-full max-w-xs border-t border-line/50 pt-4">
|
||||
{control ? (
|
||||
<div className="flex items-center justify-between gap-3 text-left">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-1 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-1 text-[10px] leading-snug text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
{control.controller === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acquireControl}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-left">
|
||||
{!controlError && (
|
||||
<div className="h-8 rounded border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
|
||||
)}
|
||||
{controlError && <p className="mt-2 text-[10px] leading-snug text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-ink">Desktop</h3>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
|
||||
{status.mode || "desktop-control"} · {status.protocol || "display"}
|
||||
</p>
|
||||
</div>
|
||||
<DisplayControlBar
|
||||
control={control}
|
||||
controlBusy={controlBusy}
|
||||
controlError={controlError}
|
||||
hasSession={!!sessionUrl}
|
||||
onAcquire={acquireControl}
|
||||
onRelease={releaseControl}
|
||||
/>
|
||||
</div>
|
||||
{sessionUrl ? (
|
||||
<DesktopStream sessionUrl={sessionUrl} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">Take control to open the desktop.</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
The display service is ready. Control access opens a short-lived desktop stream.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayControlBar({
|
||||
control,
|
||||
controlBusy,
|
||||
controlError,
|
||||
hasSession,
|
||||
onAcquire,
|
||||
onRelease,
|
||||
}: {
|
||||
control: DisplayControlStatus | null;
|
||||
controlBusy: boolean;
|
||||
controlError: string | null;
|
||||
hasSession: boolean;
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{(control?.controller === "none" ||
|
||||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let rfb: RFB | null = null;
|
||||
|
||||
async function connect() {
|
||||
setStreamError(null);
|
||||
try {
|
||||
const mod = await import("@novnc/novnc");
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const stream = displayWebSocketConnection(sessionUrl);
|
||||
rfb = new mod.default(containerRef.current, stream.url, {
|
||||
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
rfb.focusOnClick = true;
|
||||
rfb.addEventListener("disconnect", (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
|
||||
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setStreamError("Desktop stream could not be opened.");
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
rfb?.disconnect();
|
||||
};
|
||||
}, [sessionUrl]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 bg-black">
|
||||
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
|
||||
{streamError && (
|
||||
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
|
||||
{streamError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
|
||||
const url = new URL(sessionUrl, window.location.href);
|
||||
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
|
||||
if (!token) throw new Error("display session token missing");
|
||||
url.hash = "";
|
||||
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return { url: url.toString(), token };
|
||||
}
|
||||
|
||||
function displayControlActorLabel(control: DisplayControlStatus): string {
|
||||
if (control.controller === "agent") return "Agent";
|
||||
if (control.controlled_by === "admin-token") return "Admin";
|
||||
if (control.controlled_by?.startsWith("org-token:")) return "Automation";
|
||||
return "User";
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface MemoryEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
version?: number;
|
||||
expires_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const AWARENESS_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_AWARENESS_URL || "http://localhost:37800";
|
||||
|
||||
export function MemoryTab({ workspaceId }: Props) {
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAwareness, setShowAwareness] = useState(true);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newTTL, setNewTTL] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editTTL, setEditTTL] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
const awarenessUrl = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return AWARENESS_BASE_URL;
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const awarenessStatus = useMemo(() => {
|
||||
try {
|
||||
const url = new URL(AWARENESS_BASE_URL);
|
||||
return url.origin.includes("localhost") ? "local" : url.hostname;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMemory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<MemoryEntry[]>(`/workspaces/${workspaceId}/memory`);
|
||||
setEntries(data);
|
||||
} catch (e) {
|
||||
setEntries([]);
|
||||
setError(e instanceof Error ? e.message : "Failed to load memory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemory();
|
||||
}, [loadMemory]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError(null);
|
||||
if (!newKey.trim()) {
|
||||
setError("Key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = newValue;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { key: newKey, value: parsedValue };
|
||||
if (newTTL) {
|
||||
const ttl = parseInt(newTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
setNewTTL("");
|
||||
setShowAdd(false);
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to add");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`);
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
if (expanded === key) setExpanded(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete entry");
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (entry: MemoryEntry) => {
|
||||
setEditError(null);
|
||||
setEditingKey(entry.key);
|
||||
// Stringify objects/arrays as pretty JSON; render plain strings raw so the
|
||||
// editor doesn't surprise users with surrounding quotes.
|
||||
setEditValue(
|
||||
typeof entry.value === "string"
|
||||
? entry.value
|
||||
: JSON.stringify(entry.value, null, 2),
|
||||
);
|
||||
if (entry.expires_at) {
|
||||
const remainingMs = new Date(entry.expires_at).getTime() - Date.now();
|
||||
const ttl = Math.max(0, Math.floor(remainingMs / 1000));
|
||||
setEditTTL(ttl > 0 ? String(ttl) : "");
|
||||
} else {
|
||||
setEditTTL("");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingKey(null);
|
||||
setEditValue("");
|
||||
setEditTTL("");
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const handleEditSave = async (entry: MemoryEntry) => {
|
||||
setEditError(null);
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(editValue);
|
||||
} catch {
|
||||
parsedValue = editValue;
|
||||
}
|
||||
|
||||
// if_match_version closes the silent-overwrite hole when two writers
|
||||
// race. The handler returns 409 with the current version on mismatch
|
||||
// — surface that as a retry hint and reload to pick up the new state.
|
||||
const body: Record<string, unknown> = { key: entry.key, value: parsedValue };
|
||||
if (typeof entry.version === "number") {
|
||||
body.if_match_version = entry.version;
|
||||
}
|
||||
if (editTTL) {
|
||||
const ttl = parseInt(editTTL);
|
||||
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/workspaces/${workspaceId}/memory`, body);
|
||||
cancelEdit();
|
||||
loadMemory();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Failed to save";
|
||||
if (message.includes("409") || /if_match_version mismatch/i.test(message)) {
|
||||
setEditError("This entry changed since you opened it. Reloading.");
|
||||
loadMemory();
|
||||
} else {
|
||||
setEditError(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openAwareness = () => {
|
||||
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{error && !showAdd && (
|
||||
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
|
||||
<p className="text-[10px] text-ink-mid">
|
||||
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness((prev) => !prev)}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{showAwareness ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAwareness}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAwareness ? (
|
||||
AWARENESS_BASE_URL ? (
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-surface-sunken/70 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
|
||||
<iframe
|
||||
title="Awareness dashboard"
|
||||
src={awarenessUrl}
|
||||
className="h-[520px] w-full border-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
|
||||
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
|
||||
<p className="text-[10px] text-ink-mid truncate">
|
||||
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
|
||||
<span className="font-medium text-good">Connected</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
|
||||
<span className="font-medium text-ink">{awarenessStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
|
||||
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-line/60 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
|
||||
<p className="text-[10px] text-ink-mid">
|
||||
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Advanced"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMemory}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvanced && showAdd && (
|
||||
<div className="bg-surface-card rounded p-3 space-y-2 border border-line">
|
||||
<input
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
aria-label="Memory key"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<textarea
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder='Value (JSON or plain text)'
|
||||
rows={3}
|
||||
aria-label="Memory value (JSON or plain text)"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<input
|
||||
value={newTTL}
|
||||
onChange={(e) => setNewTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (optional)"
|
||||
aria-label="TTL in seconds (optional)"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{error && <div role="alert" className="text-xs text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvanced ? (
|
||||
entries.length === 0 ? (
|
||||
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="bg-surface-card rounded border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
aria-expanded={expanded === entry.key}
|
||||
>
|
||||
<span className="text-xs font-mono text-accent">{entry.key}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.expires_at && (
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
TTL {new Date(entry.expires_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-ink-mid">
|
||||
{expanded === entry.key ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === entry.key && (
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
{editingKey === entry.key ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
rows={4}
|
||||
aria-label={`Edit value for ${entry.key}`}
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<input
|
||||
value={editTTL}
|
||||
onChange={(e) => setEditTTL(e.target.value)}
|
||||
placeholder="TTL in seconds (blank = no expiry)"
|
||||
aria-label={`Edit TTL for ${entry.key}`}
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{editError && (
|
||||
<div role="alert" className="text-[10px] text-bad">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditSave(entry)}
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(entry.value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingKey !== entry.key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginEdit(entry)}
|
||||
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
|
||||
<p className="text-[10px] text-ink-mid truncate">
|
||||
KV entries remain available if you need the raw platform store.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/runtime-names", () => ({
|
||||
runtimeDisplayName: (runtime: string) => runtime,
|
||||
}));
|
||||
|
||||
import { ContainerConfigTab } from "../ContainerConfigTab";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ContainerConfigTab", () => {
|
||||
it("renders read-only runtime and container settings separate from compute shape", () => {
|
||||
render(
|
||||
<ContainerConfigTab
|
||||
data={{
|
||||
runtime: "claude-code",
|
||||
status: "online",
|
||||
needsRestart: false,
|
||||
activeTasks: 2,
|
||||
maxConcurrentTasks: 3,
|
||||
workspaceAccess: "read_write",
|
||||
deliveryMode: "poll",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Runtime image")).toBeTruthy();
|
||||
expect(screen.getByText("claude-code")).toBeTruthy();
|
||||
expect(screen.getByText("Workspace access")).toBeTruthy();
|
||||
expect(screen.getByText("read-write")).toBeTruthy();
|
||||
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
expect(screen.getByText("/workspace")).toBeTruthy();
|
||||
expect(screen.getByText("Container privileges")).toBeTruthy();
|
||||
expect(screen.queryByText("Instance type")).toBeNull();
|
||||
expect(screen.queryByText("Root volume")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRFBConstructor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@novnc/novnc", () => ({
|
||||
default: class MockRFB extends EventTarget {
|
||||
scaleViewport = false;
|
||||
resizeSession = false;
|
||||
focusOnClick = false;
|
||||
target: HTMLElement;
|
||||
url: string;
|
||||
options?: { wsProtocols?: string[] };
|
||||
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[] }) {
|
||||
super();
|
||||
this.target = target;
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
mockRFBConstructor(target, url, options);
|
||||
}
|
||||
disconnect() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import { DisplayTab } from "../DisplayTab";
|
||||
|
||||
describe("DisplayTab", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockRFBConstructor.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_not_enabled",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-no-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
|
||||
expect(mockGet).not.toHaveBeenCalledWith("/workspaces/ws-no-display/display/control");
|
||||
});
|
||||
|
||||
it("renders control acquisition for display-configured workspaces", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display");
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Admin")).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for takeover before opening a ready display stream", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Take control to open the desktop.")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the trusted noVNC client after takeover returns a stream URL", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Workspace desktop")).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
expect(mockRFBConstructor).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.stringContaining("/workspaces/ws-display/display/session/websockify"),
|
||||
{ wsProtocols: ["binary", "molecule-display-token.signed"] },
|
||||
);
|
||||
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Release" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
|
||||
});
|
||||
|
||||
it("renders active display control locks as observe-only", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "agent",
|
||||
controlled_by: "sidecar",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Release" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels org-token display control locks as automation", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "org-token:abc123",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Automation")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText("org-token:abc123")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes display control state after failed acquisition", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "agent",
|
||||
controlled_by: "sidecar",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockRejectedValueOnce(new Error("API POST /workspaces/ws-display/display/control/acquire: 409 conflict"));
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Failed to take control")).toBeTruthy();
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
|
||||
controller: "user",
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps display status visible without takeover actions when control status fails", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display/control: 401 unauthorized"));
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display session is not ready.")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
|
||||
expect(screen.getByText("Display control unavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render raw display status errors", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display: 500 secret backend details"));
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display status unavailable")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText(/secret backend details/)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores stale acquire responses after workspace changes", async () => {
|
||||
const acquire = deferred<{ controller: "user"; controlled_by: string; expires_at: string }>();
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_session_unavailable",
|
||||
mode: "desktop-control",
|
||||
status: "not_configured",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockReturnValueOnce(acquire.promise);
|
||||
|
||||
const { rerender } = render(<DisplayTab workspaceId="ws-a" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
rerender(<DisplayTab workspaceId="ws-b" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-b/display/control");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
acquire.resolve({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
await acquire.promise;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Controlled by Admin")).toBeNull();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for MemoryTab — awareness dashboard + workspace KV memory management.
|
||||
*
|
||||
* Coverage:
|
||||
* - Loading state
|
||||
* - Error state when GET /memory fails
|
||||
* - Empty state (no memory entries)
|
||||
* - Memory list rendering (single + multiple entries)
|
||||
* - Expand/collapse memory entries
|
||||
* - Add memory entry (key + value + TTL)
|
||||
* - Add validates required key
|
||||
* - Add parses JSON values
|
||||
* - Delete memory entry
|
||||
* - Edit memory entry (inline)
|
||||
* - Edit 409 conflict shows retry hint
|
||||
* - Advanced toggle shows/hides KV section
|
||||
* - Awareness dashboard expand/collapse
|
||||
* - Awareness URL includes workspaceId
|
||||
* - Refresh button reloads memory
|
||||
* - Error clears when appropriate actions are taken
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryTab } from "../MemoryTab";
|
||||
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet, post: mockPost, del: mockDel },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MEMORY_ENTRY = {
|
||||
key: "user_context",
|
||||
value: { name: "Alice", role: "engineer" },
|
||||
version: 3,
|
||||
expires_at: null,
|
||||
updated_at: new Date(Date.now() - 60000).toISOString(),
|
||||
};
|
||||
|
||||
function entry(overrides: Partial<typeof MEMORY_ENTRY> = {}): typeof MEMORY_ENTRY {
|
||||
return { ...MEMORY_ENTRY, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
function typeIn(el: HTMLElement, value: string) {
|
||||
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fireEvent.change(el as any, { target: el });
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryTab", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockDel.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Loading / Error ──────────────────────────────────────────────────────────
|
||||
|
||||
it("shows loading state when memory is being fetched", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading memory...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error banner when GET /memory rejects", async () => {
|
||||
mockGet.mockRejectedValue(new Error("network failure"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/network failure/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Failed to load memory' when GET rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown error");
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Failed to load memory/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Awareness Dashboard ─────────────────────────────────────────────────────
|
||||
|
||||
it("shows Awareness dashboard section", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an iframe with workspaceId in URL", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-xyz" />);
|
||||
await flush();
|
||||
const iframe = screen.getByTitle("Awareness dashboard");
|
||||
expect(iframe.getAttribute("src")).toContain("workspaceId=ws-xyz");
|
||||
});
|
||||
|
||||
it("shows 'Connected' status", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows workspace ID in the status grid", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-test-id" />);
|
||||
await flush();
|
||||
// workspaceId appears in two places (description + status grid).
|
||||
// Target the font-mono span in the status grid specifically.
|
||||
const spans = Array.from(document.querySelectorAll("span.font-mono"));
|
||||
expect(spans.some(s => s.textContent === "ws-test-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Collapse' and 'Open' buttons for awareness (starts visible)", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /collapse/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides awareness iframe when Collapse is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
expect(screen.getByText(/awareness dashboard is collapsed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-shows awareness iframe when collapsed state Expand is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// Start with awareness visible (default) — verify iframe is there
|
||||
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
|
||||
// Click Collapse in the awareness header to hide the iframe
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
|
||||
// The collapsed awareness state has a different "Expand" button.
|
||||
// Directly click the button whose text is exactly "Expand".
|
||||
const allBtns = screen.getAllByRole("button");
|
||||
const expandInCollapsed = allBtns.find(b => b.textContent?.trim() === "Expand");
|
||||
expect(expandInCollapsed).toBeTruthy();
|
||||
act(() => { expandInCollapsed!.click(); });
|
||||
await flush();
|
||||
expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── KV Memory: Empty / Advanced toggle ───────────────────────────────────────
|
||||
|
||||
it("shows 'Advanced workspace memory is hidden' when advanced is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/advanced workspace memory is hidden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Show' button when advanced is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /show/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Hide Advanced' after clicking Show", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /hide advanced/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state 'No memory entries' when advanced is shown and list is empty", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("No memory entries")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── KV Memory: List rendering ───────────────────────────────────────────────
|
||||
|
||||
it("renders memory entries when advanced is open", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("user_context")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple memory entries", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
entry({ key: "key1", value: "value1" }),
|
||||
entry({ key: "key2", value: "value2" }),
|
||||
]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("key1")).toBeTruthy();
|
||||
expect(screen.getByText("key2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows chevron pointing right when entry is collapsed", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows chevron pointing down when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows entry value when expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry({ value: { foo: "bar" } })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/"foo": "bar"/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows updated_at timestamp when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/updated:/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Edit and Delete buttons when entry is expanded", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows TTL when entry has expires_at", async () => {
|
||||
const future = new Date(Date.now() + 3600000).toISOString();
|
||||
mockGet.mockResolvedValue([entry({ expires_at: future })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText(/ttl/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Add Memory Entry ─────────────────────────────────────────────────────────
|
||||
|
||||
it("shows + Add button in KV section", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /\+ add/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens add form when + Add is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Memory key")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Memory value (JSON or plain text)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("requires key to be non-empty", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/key is required/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("POSTs correct payload when adding a string value", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "my_key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "plain text value");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "my_key", value: "plain text value" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POSTs parsed JSON when value is valid JSON", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "config");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, '{"debug": true}');
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "config", value: { debug: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POSTs with ttl_seconds when TTL is provided", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "temp_data");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "value");
|
||||
typeIn(screen.getByLabelText("TTL in seconds (optional)") as HTMLElement, "3600");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "temp_data", value: "value", ttl_seconds: 3600 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when add fails", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockRejectedValue(new Error("add failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/add failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes add form and refreshes after successful add", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Memory key") as HTMLElement, "new_key");
|
||||
typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "new_val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
});
|
||||
|
||||
it("closes add form when Cancel is clicked", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Memory key")).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete Memory Entry ─────────────────────────────────────────────────────
|
||||
|
||||
it("calls DEL when Delete is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(mockDel).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory/user_context",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes entry from list after successful delete", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("user_context")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.queryByText("user_context")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("collapses entry if it was expanded when deleted", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
// Expand the entry
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
// Delete
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.queryByText("user_context")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows error when delete fails", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockDel.mockRejectedValue(new Error("delete failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
await flush();
|
||||
expect(screen.getByText(/delete failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Edit Memory Entry ────────────────────────────────────────────────────────
|
||||
|
||||
it("shows edit form when Edit is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pre-fills edit form with existing value", async () => {
|
||||
mockGet.mockResolvedValue([entry({ value: { name: "Alice" } })]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const textarea = screen.getByLabelText(/edit value for user_context/i);
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain("Alice");
|
||||
});
|
||||
|
||||
it("POSTs updated value when Save is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "updated_value");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/edit value for user_context/i)).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory",
|
||||
expect.objectContaining({ key: "user_context", value: "updated_value", if_match_version: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows retry hint on 409 conflict during edit", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockRejectedValue(new Error("409 Conflict: if_match_version mismatch"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "new_val");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/this entry changed since you opened it/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows generic error when edit save fails", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
mockPost.mockRejectedValue(new Error("save failed"));
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "x");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/save failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes edit form when Cancel is clicked", async () => {
|
||||
mockGet.mockResolvedValue([entry()]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("user_context"));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText(/edit value for/i)).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
it("Refresh button calls loadMemory", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<MemoryTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -12,47 +12,35 @@ import { resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
it('maps "langgraph" to "langgraph"', () => {
|
||||
expect(resolveRuntime("langgraph")).toBe("langgraph");
|
||||
});
|
||||
|
||||
it('maps "claude-code-default" to "claude-code"', () => {
|
||||
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
|
||||
});
|
||||
|
||||
it('maps "codex" to "codex"', () => {
|
||||
expect(resolveRuntime("codex")).toBe("codex");
|
||||
});
|
||||
|
||||
it('maps "hermes" to "hermes"', () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it('maps "openclaw" to "openclaw"', () => {
|
||||
expect(resolveRuntime("openclaw")).toBe("openclaw");
|
||||
});
|
||||
|
||||
it('maps "deepagents" to "deepagents"', () => {
|
||||
expect(resolveRuntime("deepagents")).toBe("deepagents");
|
||||
});
|
||||
|
||||
it('maps "crewai" to "crewai"', () => {
|
||||
expect(resolveRuntime("crewai")).toBe("crewai");
|
||||
});
|
||||
|
||||
it('maps "autogen" to "autogen"', () => {
|
||||
expect(resolveRuntime("autogen")).toBe("autogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity fallback for modern template ids", () => {
|
||||
it("returns the id unchanged when not in the map", () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips trailing -default suffix as fallback", () => {
|
||||
expect(resolveRuntime("hermes-default")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips -default only when it is the suffix", () => {
|
||||
// "default-something" should NOT strip
|
||||
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
|
||||
expect(resolveRuntime("default-custom")).toBe("default-custom");
|
||||
});
|
||||
|
||||
it("returns the id unchanged when id has no -default suffix", () => {
|
||||
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
|
||||
expect(resolveRuntime("custom-runtime")).toBe("custom-runtime");
|
||||
});
|
||||
|
||||
it("handles custom template ids from community templates", () => {
|
||||
|
||||
@@ -13,11 +13,9 @@ import { runtimeDisplayName } from "../runtime-names";
|
||||
describe("runtimeDisplayName", () => {
|
||||
it.each([
|
||||
["claude-code", "Claude Code"],
|
||||
["langgraph", "LangGraph"],
|
||||
["deepagents", "DeepAgents"],
|
||||
["codex", "Codex"],
|
||||
["hermes", "Hermes"],
|
||||
["openclaw", "OpenClaw"],
|
||||
["crewai", "CrewAI"],
|
||||
["autogen", "AutoGen"],
|
||||
])("known runtime %q maps to %q", (input, expected) => {
|
||||
expect(runtimeDisplayName(input)).toBe(expected);
|
||||
});
|
||||
@@ -25,7 +23,6 @@ describe("runtimeDisplayName", () => {
|
||||
it("unknown runtime falls back to the input string verbatim", () => {
|
||||
// A future runtime not yet in the lookup map should render with
|
||||
// its own id — better than a generic placeholder for ops debugging.
|
||||
expect(runtimeDisplayName("hermes")).toBe("hermes");
|
||||
expect(runtimeDisplayName("custom-runtime-9000")).toBe(
|
||||
"custom-runtime-9000",
|
||||
);
|
||||
@@ -43,6 +40,6 @@ describe("runtimeDisplayName", () => {
|
||||
// the input "for safety" doesn't silently change behavior — the
|
||||
// upstream slug is already normalized lowercase.
|
||||
expect(runtimeDisplayName("Claude-Code")).toBe("Claude-Code");
|
||||
expect(runtimeDisplayName("LANGGRAPH")).toBe("LANGGRAPH");
|
||||
expect(runtimeDisplayName("CODEX")).toBe("CODEX");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,12 +63,10 @@ export interface Template extends TemplateLike {
|
||||
* id needs a non-identity mapping. */
|
||||
export function resolveRuntime(templateId: string): string {
|
||||
const runtimeMap: Record<string, string> = {
|
||||
langgraph: "langgraph",
|
||||
"claude-code-default": "claude-code",
|
||||
codex: "codex",
|
||||
hermes: "hermes",
|
||||
openclaw: "openclaw",
|
||||
deepagents: "deepagents",
|
||||
crewai: "crewai",
|
||||
autogen: "autogen",
|
||||
};
|
||||
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
|
||||
const RUNTIME_NAMES: Record<string, string> = {
|
||||
"claude-code": "Claude Code",
|
||||
langgraph: "LangGraph",
|
||||
deepagents: "DeepAgents",
|
||||
codex: "Codex",
|
||||
hermes: "Hermes",
|
||||
openclaw: "OpenClaw",
|
||||
crewai: "CrewAI",
|
||||
autogen: "AutoGen",
|
||||
kimi: "Kimi",
|
||||
"kimi-cli": "Kimi CLI",
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface RuntimeProfile {
|
||||
}
|
||||
|
||||
/** The floor every runtime inherits unless it overrides. Calibrated for
|
||||
* docker-local fast runtimes (claude-code, langgraph, crewai) where cold
|
||||
* docker-local fast runtimes (claude-code, codex, openclaw) where cold
|
||||
* boot is 30-90s. */
|
||||
export const DEFAULT_RUNTIME_PROFILE: Required<
|
||||
Pick<RuntimeProfile, "provisionTimeoutMs">
|
||||
|
||||
@@ -513,6 +513,8 @@ export function buildNodesAndEdges(
|
||||
parentId: ws.parent_id,
|
||||
currentTask: ws.current_task || "",
|
||||
runtime: ws.runtime || "",
|
||||
workspaceAccess: ws.workspace_access,
|
||||
maxConcurrentTasks: ws.max_concurrent_tasks ?? null,
|
||||
needsRestart: false,
|
||||
budgetLimit: ws.budget_limit ?? null,
|
||||
budgetUsed: ws.budget_used ?? null,
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
parentId: string | null;
|
||||
currentTask: string;
|
||||
runtime: string;
|
||||
workspaceAccess?: string | null;
|
||||
maxConcurrentTasks?: number | null;
|
||||
needsRestart: boolean;
|
||||
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
|
||||
budgetLimit: number | null;
|
||||
@@ -130,7 +132,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
deliveryMode?: string;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
|
||||
@@ -320,11 +320,13 @@ export interface WorkspaceData {
|
||||
url: string;
|
||||
parent_id: string | null;
|
||||
active_tasks: number;
|
||||
max_concurrent_tasks?: number | null;
|
||||
last_error_rate: number;
|
||||
last_sample_error: string;
|
||||
uptime_seconds: number;
|
||||
current_task: string;
|
||||
runtime: string;
|
||||
workspace_access?: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
collapsed: boolean;
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare module "@novnc/novnc" {
|
||||
export default class RFB extends EventTarget {
|
||||
scaleViewport: boolean;
|
||||
resizeSession: boolean;
|
||||
focusOnClick: boolean;
|
||||
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
|
||||
disconnect(): void;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The workspace runtime uses a **pluggable adapter architecture** — each agent infrastructure (Claude Code, OpenClaw, LangGraph, CrewAI, AutoGen, etc.) has its own adapter that bridges the A2A protocol to the infra's native interface.
|
||||
The workspace runtime uses a **pluggable adapter architecture** — each maintained agent infrastructure (Claude Code, Codex, Hermes, OpenClaw) has its own adapter that bridges the A2A protocol to the infra's native interface.
|
||||
|
||||
Adapters live in `workspace/adapters/<runtime>/` and are auto-discovered at startup. Each adapter implements `BaseAdapter` (from `adapters/base.py`) with `setup()` and `create_executor()` methods.
|
||||
|
||||
The runtime is selected via `config.yaml`:
|
||||
|
||||
```yaml
|
||||
runtime: claude-code # or: langgraph, openclaw, deepagents, crewai, autogen
|
||||
runtime: claude-code # or: codex, hermes, openclaw
|
||||
runtime_config:
|
||||
model: sonnet
|
||||
auth_token_file: .auth-token
|
||||
@@ -18,7 +18,7 @@ runtime_config:
|
||||
|
||||
## How It Works
|
||||
|
||||
The unified `workspace-template` Docker image includes both Python (LangGraph) and Node.js (CLI runtimes). At startup, `main.py` checks the `runtime` field in `config.yaml`, discovers the matching adapter in `adapters/<runtime>/`, calls `adapter.setup(config)` then `adapter.create_executor(config)` to get an `AgentExecutor` that handles A2A requests.
|
||||
The unified runtime checks the `runtime` field in `config.yaml`, discovers the matching adapter, calls `adapter.setup(config)` then `adapter.create_executor(config)` to get an `AgentExecutor` that handles A2A requests.
|
||||
|
||||
```
|
||||
A2A request arrives
|
||||
@@ -28,7 +28,7 @@ AgentExecutor.execute(context, event_queue)
|
||||
| - extracts user message from A2A parts
|
||||
| - extracts conversation history from params.metadata.history
|
||||
| - sets current_task on heartbeat (shows on canvas card)
|
||||
| - invokes the runtime (LangGraph graph, CLI subprocess, etc.)
|
||||
| - invokes the runtime adapter
|
||||
v
|
||||
Response → A2A event queue → JSON-RPC response
|
||||
```
|
||||
@@ -37,9 +37,9 @@ Response → A2A event queue → JSON-RPC response
|
||||
|
||||
Chat sessions in the Canvas UI send prior messages (up to 20) via `params.metadata.history` in each A2A `message/send` request. Executors extract this history:
|
||||
|
||||
- **LangGraph/DeepAgents**: Prepends history as `("human", text)` / `("ai", text)` tuples to the LangGraph message list
|
||||
- **CrewAI/AutoGen**: Prepends history as a text prefix in the task description (`"Conversation so far:\n..."`)
|
||||
- **Claude Code**: Uses `--resume <session_id>` for native session continuity (history not needed)
|
||||
- **Codex**: Uses the Codex runtime's native session state
|
||||
- **Hermes**: Uses Hermes' agent runtime session handling
|
||||
- **OpenClaw**: Uses `--session-id` for native session continuity
|
||||
|
||||
### Current Task Reporting
|
||||
@@ -48,10 +48,6 @@ All executors update the workspace's `current_task` via the heartbeat during exe
|
||||
|
||||
## Built-in Adapters
|
||||
|
||||
### LangGraph (`runtime: langgraph`) — Default
|
||||
|
||||
Full Python agent with LangGraph ReAct pattern. Supports skills, tools, plugins, peer coordination, and team routing.
|
||||
|
||||
### Claude Code (`runtime: claude-code`)
|
||||
|
||||
```yaml
|
||||
@@ -71,35 +67,18 @@ The SDK uses the same Claude Code engine under the hood — plugins, CLAUDE.md d
|
||||
|
||||
**Important:** Claude Code refuses to run as root with `--dangerously-skip-permissions`. The Dockerfile creates a non-root `agent` user.
|
||||
|
||||
### CrewAI (`runtime: crewai`)
|
||||
|
||||
Role-based multi-agent framework. Creates a CrewAI Agent + Task + Crew per request with A2A delegation tools (`delegate_to_peer`, `list_available_peers`).
|
||||
### Codex (`runtime: codex`)
|
||||
|
||||
```yaml
|
||||
runtime: crewai
|
||||
model: openrouter:google/gemini-2.5-flash
|
||||
runtime: codex
|
||||
model: openai/gpt-5.3-codex
|
||||
```
|
||||
|
||||
**Auth:** Uses `OPENROUTER_API_KEY` or `OPENAI_API_KEY` env var.
|
||||
|
||||
### AutoGen (`runtime: autogen`)
|
||||
|
||||
Microsoft AutoGen AssistantAgent with tool use. Creates an `AssistantAgent` per request with A2A delegation tools.
|
||||
### Hermes (`runtime: hermes`)
|
||||
|
||||
```yaml
|
||||
runtime: autogen
|
||||
model: openai:gpt-4.1-mini
|
||||
```
|
||||
|
||||
**Auth:** Uses `OPENAI_API_KEY` env var.
|
||||
|
||||
### DeepAgents (`runtime: deepagents`)
|
||||
|
||||
LangGraph-based agent with deep planning capabilities. Uses the same `LangGraphA2AExecutor` as the default runtime but with a specialized agent setup including delegation, memory, and search tools.
|
||||
|
||||
```yaml
|
||||
runtime: deepagents
|
||||
model: openrouter:google/gemini-2.5-flash
|
||||
runtime: hermes
|
||||
model: openai/gpt-4o
|
||||
```
|
||||
|
||||
### OpenClaw (`runtime: openclaw`)
|
||||
@@ -219,9 +198,9 @@ a2a info # Show workspace info
|
||||
|
||||
Both approaches use the same backend: platform registry for discovery, A2A protocol for messaging, and access control enforcement (parent↔child, siblings only).
|
||||
|
||||
## Workspace Awareness
|
||||
## Memory Tools
|
||||
|
||||
CLI runtimes keep the same memory tool surface as the Python runtime. When `AWARENESS_URL` and `AWARENESS_NAMESPACE` are injected into the workspace, `commit_memory` and `search_memory` route to the workspace's own awareness namespace instead of the fallback platform memory API. This keeps the agent contract stable while giving each workspace an isolated memory scope.
|
||||
CLI runtimes keep the same memory tool surface as the Python runtime: `commit_memory` / `commit_memory_v2` / `search_memory` / `commit_summary` / `forget_memory` are exposed via the workspace's MCP bridge and route through the platform's v2 memory plugin under the workspace's `workspace:<id>` namespace. See [Memory Architecture](../architecture/memory.md) for the backend.
|
||||
|
||||
## Task Status Reporting
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@ env:
|
||||
required:
|
||||
- ANTHROPIC_API_KEY
|
||||
optional:
|
||||
- AWARENESS_URL
|
||||
- AWARENESS_NAMESPACE
|
||||
- ANTHROPIC_BASE_URL
|
||||
- OPENAI_BASE_URL
|
||||
- GSC_CLIENT_ID
|
||||
|
||||
@@ -4,13 +4,11 @@ The `workspace/` directory is Molecule AI's unified runtime image. Every provisi
|
||||
|
||||
## Runtime Matrix In Current `main`
|
||||
|
||||
Current `main` ships six adapters:
|
||||
Current `main` ships four maintained adapters:
|
||||
|
||||
- `langgraph`
|
||||
- `deepagents`
|
||||
- `claude-code`
|
||||
- `crewai`
|
||||
- `autogen`
|
||||
- `codex`
|
||||
- `hermes`
|
||||
- `openclaw`
|
||||
|
||||
This is the merged runtime surface today. Branch-level experiments such as NemoClaw are separate and should be treated as roadmap/WIP, not merged support.
|
||||
@@ -27,7 +25,7 @@ Adapter-specific behavior is documented in [Agent Runtime Adapters](./cli-runtim
|
||||
- serving A2A over HTTP
|
||||
- registering with the platform and sending heartbeats
|
||||
- reporting activity and task state
|
||||
- integrating with awareness-backed memory when configured
|
||||
- proxying durable memory tools through the v2 memory plugin
|
||||
- hot-reloading skills while the workspace is running
|
||||
|
||||
## Environment Model
|
||||
@@ -39,8 +37,6 @@ WORKSPACE_ID=ws-123
|
||||
WORKSPACE_CONFIG_PATH=/configs
|
||||
PLATFORM_URL=http://platform:8080
|
||||
PARENT_ID=
|
||||
AWARENESS_URL=http://awareness:37800
|
||||
AWARENESS_NAMESPACE=workspace:ws-123
|
||||
LANGFUSE_HOST=http://langfuse-web:3000
|
||||
LANGFUSE_PUBLIC_KEY=...
|
||||
LANGFUSE_SECRET_KEY=...
|
||||
@@ -49,8 +45,7 @@ LANGFUSE_SECRET_KEY=...
|
||||
Important behavior:
|
||||
|
||||
- `WORKSPACE_CONFIG_PATH` points at the mounted config directory for that workspace.
|
||||
- `AWARENESS_URL` + `AWARENESS_NAMESPACE` enable workspace-scoped awareness-backed memory.
|
||||
- If awareness is absent, runtime memory tools fall back to the platform memory endpoints for compatibility.
|
||||
- Memory MCP tools route through the platform's v2 memory plugin (see Memory Architecture doc); there is no per-workspace memory env var anymore — the plugin sidecar is provisioned at the tenant EC2 boundary.
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
@@ -82,8 +77,7 @@ At a high level, `workspace/main.py` does this:
|
||||
| `skills/loader.py` | Parses `SKILL.md`, loads tool modules, returns loaded skill metadata |
|
||||
| `skills/watcher.py` | Hot reload path for skill changes |
|
||||
| `plugins.py` | Scans mounted plugins for shared rules, prompt fragments, and extra skills |
|
||||
| `tools/memory.py` | Agent memory tools |
|
||||
| `tools/awareness_client.py` | Awareness-backed persistence wrapper |
|
||||
| `tools/memory.py` | Agent memory tools (route through the platform's v2 memory plugin via the workspace-server proxy) |
|
||||
| `coordinator.py` | Coordinator-only delegation path for team leads |
|
||||
|
||||
## Skills, Plugins, And Hot Reload
|
||||
@@ -103,23 +97,28 @@ Hot reload matters because the runtime is designed to keep a workspace alive whi
|
||||
|
||||
The watcher rescans the skill package, rebuilds the agent tool surface, and updates the Agent Card so peers and the canvas reflect the new capabilities.
|
||||
|
||||
## Awareness And Memory Integration
|
||||
## Memory Integration
|
||||
|
||||
The runtime keeps the agent-facing contract stable:
|
||||
|
||||
- `commit_memory(content, scope)`
|
||||
- `search_memory(query, scope)`
|
||||
- `commit_memory(content, scope)` — legacy MCP name, routed through the
|
||||
v2 plugin's scope→namespace shim
|
||||
- `commit_memory_v2(content, namespace)` — direct v2 surface
|
||||
- `search_memory(query, namespace?)` — v2 plugin search with FTS +
|
||||
semantic scoring when the plugin declares the capability
|
||||
|
||||
When awareness is configured:
|
||||
All writes land in the workspace's `workspace:<workspace_id>` namespace
|
||||
unless the agent passes an explicit one. Cross-workspace namespaces
|
||||
(`team:<root>`, `org:<root>`) follow the platform's namespace ACL
|
||||
(`internal/memory/namespace/resolver.go`). There is no per-workspace
|
||||
memory env var on the runtime side — the plugin lives on the tenant
|
||||
EC2 at `localhost:9100`, spawned by `entrypoint-tenant.sh` when
|
||||
`MEMORY_PLUGIN_URL` is present in the tenant-server's env (CP
|
||||
user-data injects it during tenant provisioning). The workspace-server
|
||||
proxies all memory calls through that sidecar.
|
||||
|
||||
- the tools route durable facts to the workspace's own awareness namespace
|
||||
- the namespace defaults to `workspace:<workspace_id>` unless explicitly overridden
|
||||
|
||||
When awareness is not configured:
|
||||
|
||||
- the same tools fall back to the platform memory endpoints
|
||||
|
||||
That design lets the platform improve the backend memory boundary without forcing every agent prompt or tool signature to change.
|
||||
See [Memory Architecture](../architecture/memory.md) for the full
|
||||
backend story.
|
||||
|
||||
## Coordinator Enforcement
|
||||
|
||||
|
||||
@@ -90,8 +90,6 @@ Poll `GET /workspaces/:id/delegations` to check results. Each entry includes `de
|
||||
|
||||
This is the recommended way for agents to delegate work — it works for all runtimes (Claude Code, LangGraph, etc.) since it operates at the platform level.
|
||||
|
||||
Workspace creation also assigns an `awareness_namespace` on the workspace row. That namespace is later injected into the provisioned runtime.
|
||||
|
||||
### Registry
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|
||||
@@ -103,7 +103,7 @@ Migration files live in `workspace-server/migrations/` (latest: `022_workspace_s
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `awareness_namespace`, `workspace_dir` |
|
||||
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `workspace_dir` |
|
||||
| `canvas_layouts` | Per-workspace x/y canvas position |
|
||||
| `structure_events` | Append-only event log (workspace lifecycle, agent, approval events) |
|
||||
| `activity_logs` | A2A communications, task updates, agent logs, errors. `error_detail` is populated by the scheduler so cron run history can surface failure reasons. |
|
||||
|
||||
+18
-10
@@ -47,18 +47,26 @@ It is useful for structured per-workspace state and optional TTL entries. It is
|
||||
|
||||
`GET /workspaces/:id/session-search` provides a thin recall surface over recent activity rows and memory rows. It is for “what just happened in this workspace?” rather than long-term semantic storage.
|
||||
|
||||
### 4. Awareness-backed persistence
|
||||
### 4. Memory v2 plugin (`memory_records` / `memory_namespaces`)
|
||||
|
||||
When the runtime receives:
|
||||
This is the production-direction backend, behind the RFC #2728 HTTP
|
||||
contract. The plugin runs as a sidecar on each tenant EC2 (auto-spawned
|
||||
by `entrypoint-tenant.sh` when `MEMORY_PLUGIN_URL` is set), owns its
|
||||
own tables under the `memory_plugin` schema, and serves:
|
||||
|
||||
```bash
|
||||
AWARENESS_URL=...
|
||||
AWARENESS_NAMESPACE=workspace:<id>
|
||||
```
|
||||
- `POST /workspaces/:id/v2/memories` (canvas `MemoryInspectorPanel`)
|
||||
- `GET /workspaces/:id/v2/memories`
|
||||
- `DELETE /workspaces/:id/v2/memories/:id`
|
||||
- runtime tools `commit_memory_v2`, `search_memory`, `commit_summary`,
|
||||
`forget_memory`
|
||||
- legacy MCP tool names `commit_memory` / `recall_memory` via the
|
||||
scope→namespace shim in `mcp_tools_memory_legacy_shim.go`
|
||||
|
||||
the same memory tools keep the same interface, but durable memory writes/reads are routed through the workspace's awareness namespace.
|
||||
|
||||
This is the current production direction of the memory boundary: stable tool surface, stronger backend isolation.
|
||||
Capability negotiation (FTS, embedding, TTL, pin, propagation) is
|
||||
declared by the plugin via `GET /v1/health`; workspace-server adapts
|
||||
the tool surface to what the plugin actually supports. See
|
||||
[`memory-plugin-v1.yaml`](../api-protocol/memory-plugin-v1.yaml) for
|
||||
the full wire contract.
|
||||
|
||||
## Access Model
|
||||
|
||||
@@ -121,7 +129,7 @@ If you need:
|
||||
- **org-wide guidance**: use `GLOBAL`
|
||||
- **simple UI-visible structured state**: use `workspace_memory`
|
||||
- **recent decision/task recall**: use `session-search`
|
||||
- **stronger durable isolation**: enable awareness namespaces
|
||||
- **semantic / FTS search across memories**: use the v2 plugin endpoints (`/v2/memories?q=…`); they go through the plugin's pgvector + tsvector indexes when the plugin declares the capability
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
||||
@@ -426,10 +426,10 @@ submitted → working → completed
|
||||
|
||||
| Surface | Storage | Endpoint | Purpose |
|
||||
|---------|---------|----------|---------|
|
||||
| **Scoped agent memory** | `agent_memories` table | `POST /workspaces/:id/memories` | HMA-backed distributed memory with scope enforcement |
|
||||
| **Key/value workspace memory** | `workspace_memory` table | `POST /workspaces/:id/memory` | Simple structured state, UI-visible, optional TTL |
|
||||
| **Activity recall** | `activity_logs` + `agent_memories` | `GET /workspaces/:id/session-search` | "What just happened?" contextual recall |
|
||||
| **Awareness-backed** | External service | Same tool interface | When `AWARENESS_URL` + `AWARENESS_NAMESPACE` configured |
|
||||
| **Memory v2 plugin (SSOT)** | `memory_plugin.memory_records` table via RFC #2728 HTTP plugin | `POST /workspaces/:id/v2/memories`, MCP tools `commit_memory` / `commit_memory_v2` / `commit_summary` | Production memory backend — agent reads/writes route through here exclusively |
|
||||
| **Key/value workspace memory** | `workspace_memory` table | `POST /workspaces/:id/memory` | Simple structured state, UI-visible, optional TTL — separate from agent memory |
|
||||
| **Activity recall** | `activity_logs` + `agent_memories` (legacy read-only) | `GET /workspaces/:id/session-search` | "What just happened?" contextual recall |
|
||||
| **Legacy `agent_memories`** | `agent_memories` table | `POST /workspaces/:id/memories` (REST) | Frozen post-A1 — kept only for the REST canvas-side path; the workspace-create `seedInitialMemories` writer routes through the v2 plugin once #1755 (PR #1759) lands. Scheduled for drop in Phase A3 (#1733). |
|
||||
|
||||
### Memory → Skill Compounding Flywheel
|
||||
|
||||
@@ -511,7 +511,7 @@ description: ""
|
||||
version: "1.0.0"
|
||||
tier: 2 # 1=sandboxed, 2=standard, 3=privileged, 4=full-host
|
||||
model: "anthropic:claude-sonnet-4-6" # provider:model syntax
|
||||
runtime: "langgraph" # langgraph | deepagents | claude-code | crewai | autogen | openclaw
|
||||
runtime: "claude-code" # claude-code | codex | hermes | openclaw
|
||||
runtime_config: # Runtime-specific settings
|
||||
command: "claude" # For CLI runtimes
|
||||
args: []
|
||||
@@ -565,15 +565,13 @@ compliance:
|
||||
max_task_duration_seconds: 300
|
||||
```
|
||||
|
||||
### Six Runtime Adapters
|
||||
### Four Runtime Adapters
|
||||
|
||||
| Adapter | Core Strength | Image Tag |
|
||||
|---------|--------------|-----------|
|
||||
| **LangGraph** | Graph-based state machine, tool use, streaming | `workspace-template:langgraph` |
|
||||
| **DeepAgents** | Deep planning, multi-step task decomposition | `workspace-template:deepagents` |
|
||||
| **Claude Code** | Native coding workflows, CLI continuity, OAuth auth | `workspace-template:claude-code` |
|
||||
| **CrewAI** | Role-based crews, structured task orchestration | `workspace-template:crewai` |
|
||||
| **AutoGen** | Multi-agent conversations, explicit strategies | `workspace-template:autogen` |
|
||||
| **Codex** | OpenAI Codex coding workflows | `workspace-template:codex` |
|
||||
| **Hermes** | Hermes agent runtime | `workspace-template:hermes` |
|
||||
| **OpenClaw** | CLI-native runtime, own session model | `workspace-template:openclaw` |
|
||||
|
||||
**Branch-level WIP**: NemoClaw (NVIDIA T4 + Docker socket) on `feat/nemoclaw-t4-docker`.
|
||||
@@ -740,7 +738,6 @@ requires:
|
||||
| `hitl.py` | Multi-channel HITL (dashboard, Slack, email) | hitl.bypass_roles |
|
||||
| `sandbox.py` | Code execution (subprocess or Docker backend) | sandbox access |
|
||||
| `telemetry.py` | OpenTelemetry span creation and tracing | trace emission |
|
||||
| `awareness_client.py` | Awareness namespace memory wrapper | memory scope |
|
||||
| `security_scan.py` | CVE and security scanning (pip-audit/Snyk) | security audit |
|
||||
| `temporal_workflow.py` | Temporal.io workflow integration | workflow engine |
|
||||
| `a2a_tools.py` | A2A delegation helpers and route resolution | delegate/receive |
|
||||
@@ -749,8 +746,7 @@ requires:
|
||||
|
||||
| Server | Purpose |
|
||||
|--------|---------|
|
||||
| `molecule` | 20+ platform management tools (workspace CRUD, chat, memory, teams, secrets, files, approvals) |
|
||||
| `awareness-memory` | Persistent cross-session memory via Awareness SDK |
|
||||
| `molecule` | 20+ platform management tools (workspace CRUD, chat, memory, teams, secrets, files, approvals) — includes `commit_memory` / `commit_memory_v2` / `search_memory` routed through the v2 plugin |
|
||||
|
||||
---
|
||||
|
||||
@@ -909,7 +905,7 @@ Postgres + Redis + Langfuse only (for local development without containerized wo
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,...` | CORS whitelist |
|
||||
| `RATE_LIMIT` | `600` | Requests per minute |
|
||||
| `WORKSPACE_DIR` | Optional | Shared workspace volume |
|
||||
| `AWARENESS_URL` | Optional | Awareness service URL |
|
||||
| `MEMORY_PLUGIN_URL` | Unset by default | v2 memory plugin sidecar address. Typically set externally — CP user-data injects `http://localhost:9100` on tenant EC2 boot, which `entrypoint-tenant.sh` reads as the signal to spawn the bundled `memory-plugin` sidecar on the matching loopback port. When unset, today (pre-#1747) the legacy `agent_memories` SQL path is used as silent fallback; after #1747 (RFC #1733 Phase A1) lands, memory MCP tools return a "plugin not configured" error instead. |
|
||||
|
||||
### Canvas (Next.js)
|
||||
|
||||
@@ -927,8 +923,6 @@ Postgres + Redis + Langfuse only (for local development without containerized wo
|
||||
| `WORKSPACE_CONFIG_PATH` | `/configs` | Config directory mount |
|
||||
| `PLATFORM_URL` | `http://platform:8080` | Platform connection |
|
||||
| `PARENT_ID` | Empty | Parent workspace ID (set if nested) |
|
||||
| `AWARENESS_URL` | Optional | Awareness service |
|
||||
| `AWARENESS_NAMESPACE` | Optional | Scoped namespace for awareness memory |
|
||||
| `LANGFUSE_HOST` | `http://langfuse-web:3000` | Langfuse endpoint |
|
||||
| `LANGFUSE_PUBLIC_KEY` | Optional | Langfuse auth |
|
||||
| `LANGFUSE_SECRET_KEY` | Optional | Langfuse auth |
|
||||
@@ -1091,20 +1085,6 @@ Every Tier 1 launch (Open Interpreter, CrewAI) had all four elements.
|
||||
}
|
||||
```
|
||||
|
||||
### Awareness MCP Server
|
||||
|
||||
For persistent cross-session memory:
|
||||
|
||||
```json
|
||||
{
|
||||
"awareness-memory": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@awareness-sdk/local", "mcp"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 29. Summary Statistics
|
||||
|
||||
@@ -44,7 +44,7 @@ All three call `onWorkspaceOffline`, which broadcasts `WORKSPACE_OFFLINE` and ca
|
||||
|
||||
### Template Resolution (Workspace Create)
|
||||
|
||||
Runtime detection happens **before** the DB insert: if `payload.Runtime` is empty and a template is specified, the handler reads `runtime:` from `configsDir/template/config.yaml` first. If still empty, it defaults to `"langgraph"`. This ensures the correct runtime (e.g. `claude-code`) is persisted in the DB and used for container image selection.
|
||||
Runtime detection happens **before** the DB insert: if `payload.Runtime` is empty and a template is specified, the handler reads `runtime:` from `configsDir/template/config.yaml` first. If still empty, it defaults to `"claude-code"`. This ensures the correct runtime is persisted in the DB and used for container image selection.
|
||||
|
||||
When the requested template does not exist, the Create handler falls back in order:
|
||||
|
||||
|
||||
+5
-5
@@ -24,10 +24,10 @@ features:
|
||||
details: Build agent organizations as nested workspaces on a live React Flow canvas with drag-to-nest hierarchy, template deployment, bundles, and real-time updates.
|
||||
icon: "🗺️"
|
||||
- title: Runtime Compatibility
|
||||
details: Current main ships adapters for LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw under one workspace contract and A2A surface.
|
||||
details: Current main ships adapters for Claude Code, Codex, Hermes, and OpenClaw under one workspace contract and A2A surface.
|
||||
icon: "⚙️"
|
||||
- title: Hierarchical Memory
|
||||
details: HMA-style LOCAL, TEAM, and GLOBAL scopes plus workspace-scoped awareness namespaces when awareness is configured.
|
||||
details: HMA-style LOCAL, TEAM, and GLOBAL scopes backed by the v2 memory plugin (per-tenant pgvector sidecar with FTS + semantic recall).
|
||||
icon: "🧠"
|
||||
- title: Skill Evolution
|
||||
details: Local SKILL.md packages, tool loading, plugin-mounted shared capabilities, hot reload, and a documented memory-to-skill promotion path.
|
||||
@@ -49,13 +49,13 @@ features:
|
||||
|---|---|
|
||||
| **Canvas** | Empty-state deployment, onboarding guide, 10-tab side panel, template palette, bundle import/export, drag-to-nest teams, search, activity and trace views |
|
||||
| **Platform** | Workspace CRUD, registry, A2A proxy, team expansion, approvals, secrets, global secrets, memory APIs, files API, terminal, viewport persistence, WebSocket fanout |
|
||||
| **Runtime** | One workspace image with six shipping adapters on `main`: LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, OpenClaw |
|
||||
| **Memory** | Scoped agent memories, key/value workspace memory, session-search recall, awareness namespace injection |
|
||||
| **Runtime** | One workspace image with four shipping adapters on `main`: Claude Code, Codex, Hermes, OpenClaw |
|
||||
| **Memory** | v2 plugin (pgvector + FTS) serving scoped agent memories under per-workspace namespaces; key/value workspace memory; session-search recall |
|
||||
| **Skills** | Local skill packages, plugin-mounted shared skills/rules, audit/install/publish CLI helpers, hot reload |
|
||||
|
||||
## Compatibility Note
|
||||
|
||||
`main` currently ships six runtime adapters. `NemoClaw` appears in branch-level work (`feat/nemoclaw-t4-docker`) and is not documented here as merged `main` functionality.
|
||||
`main` currently ships four runtime adapters: Claude Code, Codex, Hermes, and OpenClaw. `NemoClaw` appears in branch-level work (`feat/nemoclaw-t4-docker`) and is not documented here as merged `main` functionality.
|
||||
|
||||
## Recommended Reading
|
||||
|
||||
|
||||
+2
-2
@@ -238,7 +238,7 @@ No inbound firewall rules needed — the agent initiates the outbound WebSocket
|
||||
## What To Try Next
|
||||
|
||||
- **Expand to a team:** right-click a workspace and choose `Expand to Team`.
|
||||
- **Switch runtime:** use `Config -> Runtime` to move between LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw.
|
||||
- **Switch runtime:** use `Config -> Runtime` to move between Claude Code, Codex, Hermes, and OpenClaw.
|
||||
- **Inspect operations:** check `Activity`, `Traces`, `Events`, and `Terminal`.
|
||||
- **Use global keys:** configure one provider once in `Secrets & API Keys -> Global`.
|
||||
- **Import a template:** use the template palette or `POST /templates/import`.
|
||||
@@ -268,7 +268,7 @@ Browser --> Canvas (Next.js :3000)
|
||||
|
|
||||
v
|
||||
Provisioned workspaces
|
||||
(LangGraph / Claude Code / CrewAI / AutoGen / etc.)
|
||||
(Claude Code / Codex / Hermes / OpenClaw)
|
||||
```
|
||||
|
||||
For the full system model, see [Architecture](./architecture/architecture.md).
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Runbook: stale CI umbrella with green sub-jobs — compensating status
|
||||
|
||||
**When to use this:** A PR's `CI / all-required (pull_request)` status is `failure` (so branch protection blocks the merge), but all 5 required sub-jobs (`Detect changes`, `Platform (Go)`, `Canvas (Next.js)`, `Shellcheck (E2E scripts)`, `Python Lint & Test`) actually succeeded. The umbrella job's internal 40-min poll deadline elapsed before the success statuses propagated through Gitea's commit-status pipeline.
|
||||
|
||||
**When NOT to use this:** Any required sub-job actually failed. The umbrella correctly reflects reality; a compensating-status post would lie.
|
||||
|
||||
This pattern parallels what `.gitea/workflows/status-reaper.yml` does for default-branch `(push)` status drift, but applied to PR umbrellas instead of main-branch contexts.
|
||||
|
||||
## Diagnose
|
||||
|
||||
1. Look up the umbrella status:
|
||||
|
||||
```bash
|
||||
TOK=$(cat ~/.molecule-ai/gitea-token)
|
||||
API=https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-core
|
||||
PR=<pr-number>
|
||||
sha=$(curl -sS -H "Authorization: token $TOK" "$API/pulls/$PR" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
curl -sS -H "Authorization: token $TOK" "$API/commits/$sha/status" \
|
||||
| python3 -c "import sys,json; [print(s['status'], s['context']) for s in json.load(sys.stdin)['statuses'] if 'all-required' in s['context']]"
|
||||
```
|
||||
|
||||
2. Look up the actual sub-job statuses in the Gitea DB:
|
||||
|
||||
```bash
|
||||
ssh root@5.78.80.188 "docker exec molecule-postgres-1 psql -U gitea -d gitea -tAc \"
|
||||
SELECT aj.name,
|
||||
CASE aj.status WHEN 1 THEN 'success' WHEN 2 THEN 'failure' WHEN 3 THEN 'cancelled' WHEN 4 THEN 'skipped' WHEN 5 THEN 'waiting' WHEN 6 THEN 'running' END
|
||||
FROM action_run ar JOIN action_run_job aj ON aj.run_id=ar.id
|
||||
WHERE ar.repo_id=17 AND ar.workflow_id='ci.yml' AND ar.commit_sha='$sha'
|
||||
ORDER BY aj.id;\""
|
||||
```
|
||||
|
||||
The 5 required-by-umbrella sub-jobs must all be `success`. (`Canvas Deploy Reminder` is intentionally not required — its state doesn't matter.)
|
||||
|
||||
## Recover
|
||||
|
||||
If diagnosis confirms all 5 required sub-jobs are success but the umbrella is stuck at failure:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: token $TOK" -H "Content-Type: application/json" \
|
||||
"$API/statuses/$sha" -d '{
|
||||
"context": "CI / all-required (pull_request)",
|
||||
"state": "success",
|
||||
"description": "Compensating status: all 5 required sub-jobs verified success in action_run_job; umbrella stale due to commit-status propagation race. Posted by <operator> per ci-umbrella-stale-compensating-status runbook."
|
||||
}'
|
||||
```
|
||||
|
||||
The status posts immediately; the merge gate flips green within ~5 seconds.
|
||||
|
||||
**Always include WHO and WHY in the `description` field** so the audit trail is honest. Future operators (and `audit-force-merge.yml` consumers) need to be able to tell a recovery from a real bypass.
|
||||
|
||||
## Why this happens
|
||||
|
||||
- The umbrella's 40-min internal poll loop (`.gitea/workflows/ci.yml` → `all-required` job → `Wait for required CI contexts` step) treats `missing` statuses as pending.
|
||||
- Status propagation: a job completing on a runner posts its `action_run_job.status=1` row first, then Gitea's notifier walks `action_run_job` → `commit_status` table. Under high write load (many concurrent PRs synchronizing) the notifier walk can lag by several minutes.
|
||||
- If propagation lag pushes the last sub-job's commit-status past the umbrella's 40-min wall-clock deadline, the umbrella fails even though the sub-jobs were green well within the window.
|
||||
- The umbrella correctly does not retry once it has emitted a terminal status (per RFC internal#219 design — retries would mask real failures).
|
||||
|
||||
## Prevent
|
||||
|
||||
Most cases are downstream of the runner-pool dispatch deadlock fixed by commit `7da843f2` (issue #1779). With the umbrella running on the dedicated `ci-meta` pool, sub-jobs are no longer competing for runners with their own umbrella, so propagation completes well before the 40-min deadline in normal load.
|
||||
|
||||
If you find yourself reaching for this runbook frequently, that's the signal to either:
|
||||
- Raise `timeout-minutes` on the umbrella above 45.
|
||||
- Build the `umbrella-reaper.yml` auto-recovery described in issue #1780 (this runbook is its precursor).
|
||||
|
||||
## Cross-refs
|
||||
|
||||
- Issue #1780 — original write-up; tracks auto-recovery
|
||||
- Issue #1779 — runner-pool deadlock; root cause of the propagation lag in most cases
|
||||
- `.gitea/workflows/status-reaper.yml` — sibling pattern for default-branch `(push)` status drift
|
||||
- `.gitea/workflows/audit-force-merge.yml` — audits bypass merges; this runbook's `description` field is what makes a compensating-status merge auditable vs. opaque
|
||||
|
||||
## Session-local examples
|
||||
|
||||
The pattern was used twice during the 2026-05-24 CTO-bypass session:
|
||||
|
||||
- **PR #1737** merged via compensating-status — all 5 sub-jobs green, umbrella timed out on propagation race. Merge commit `d5941906`.
|
||||
- **PR #1759** merged via compensating-status — 4/5 sub-jobs green, the 5th (`Platform (Go)`) was an inherited-from-main failure (templates_test fixtures bug, tracked as #1778, fixed in #1781). The compensating-status description called out the inherited failure honestly. Merge commit `220a04b1`.
|
||||
+1
-3
@@ -28,9 +28,7 @@
|
||||
{"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"},
|
||||
{"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"},
|
||||
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
|
||||
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
|
||||
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
|
||||
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
|
||||
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}
|
||||
],
|
||||
"org_templates": [
|
||||
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
|
||||
|
||||
@@ -8,7 +8,7 @@ cd "$(dirname "$0")/../workspace-template"
|
||||
echo "=== Building base image ==="
|
||||
docker build -t workspace-template:base -t workspace-template:latest .
|
||||
|
||||
for adapter in langgraph claude_code openclaw deepagents crewai autogen; do
|
||||
for adapter in claude_code codex hermes openclaw; do
|
||||
DOCKERFILE="adapters/${adapter}/Dockerfile"
|
||||
if [ ! -f "$DOCKERFILE" ]; then
|
||||
echo "Skipping $adapter (no Dockerfile)"
|
||||
|
||||
@@ -32,7 +32,7 @@ log() { echo -e "${GREEN}[refresh]${NC} $1" >&2; }
|
||||
warn() { echo -e "${YELLOW}[refresh]${NC} $1" >&2; }
|
||||
err() { echo -e "${RED}[refresh]${NC} $1" >&2; }
|
||||
|
||||
ALL_RUNTIMES=(claude-code langgraph crewai autogen deepagents hermes gemini-cli openclaw)
|
||||
ALL_RUNTIMES=(claude-code codex hermes openclaw)
|
||||
RUNTIMES=("${ALL_RUNTIMES[@]}")
|
||||
RECREATE=true
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E test: All 6 adapters — create one agent per runtime, test A2A between all
|
||||
# E2E test: all maintained adapters — create one agent per runtime, test A2A
|
||||
set -euo pipefail
|
||||
|
||||
PLATFORM="${1:-http://localhost:8080}"
|
||||
@@ -52,12 +52,12 @@ a2a_send() {
|
||||
}
|
||||
|
||||
echo "============================================"
|
||||
echo " All-Adapters E2E Test (6 runtimes)"
|
||||
echo " All-Adapters E2E Test (4 runtimes)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# --- Create workspaces ---
|
||||
echo "--- Step 1: Create 6 workspaces ---"
|
||||
echo "--- Step 1: Create 4 workspaces ---"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Alice-Claude","role":"claude-code test","tier":2,"template":"claude-code-default"}')
|
||||
@@ -65,9 +65,9 @@ ALICE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'
|
||||
check "Create Alice (claude-code)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Bob-LangGraph","role":"langgraph test","tier":2,"template":"langgraph"}')
|
||||
-d '{"name":"Bob-Codex","role":"codex test","tier":2,"template":"codex"}')
|
||||
BOB=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Bob (langgraph)" "provisioning" "$R"
|
||||
check "Create Bob (codex)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Carol-OpenClaw","role":"openclaw test","tier":2,"template":"openclaw"}')
|
||||
@@ -75,29 +75,19 @@ CAROL=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'
|
||||
check "Create Carol (openclaw)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Dave-DeepAgents","role":"deepagents test","tier":2,"template":"deepagents"}')
|
||||
-d '{"name":"Dave-Hermes","role":"hermes test","tier":2,"template":"hermes"}')
|
||||
DAVE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Dave (deepagents)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Eve-CrewAI","role":"crewai test","tier":2,"template":"crewai"}')
|
||||
EVE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Eve (crewai)" "provisioning" "$R"
|
||||
|
||||
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Frank-AutoGen","role":"autogen test","tier":2,"template":"autogen"}')
|
||||
FRANK=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check "Create Frank (autogen)" "provisioning" "$R"
|
||||
check "Create Dave (hermes)" "provisioning" "$R"
|
||||
|
||||
# --- Set API keys (skip Claude which uses OAuth) ---
|
||||
echo ""
|
||||
echo "--- Step 2: Set API keys ---"
|
||||
for ID in $BOB $CAROL $DAVE $EVE $FRANK; do
|
||||
for ID in $BOB $CAROL $DAVE; do
|
||||
curl -s -X POST "$PLATFORM/workspaces/$ID/secrets" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"key\":\"OPENAI_API_KEY\",\"value\":\"$OPENAI_KEY\"}" > /dev/null
|
||||
done
|
||||
echo "Set OPENAI_API_KEY on 5 agents"
|
||||
echo "Set OPENAI_API_KEY on 3 agents"
|
||||
|
||||
# Auto-restart happens automatically when secrets are set
|
||||
echo "Secrets trigger auto-restart — waiting for agents to come back..."
|
||||
@@ -105,13 +95,11 @@ sleep 15
|
||||
|
||||
# --- Wait for all online ---
|
||||
echo ""
|
||||
echo "--- Step 3: Wait for agents (OpenClaw ~3min, CrewAI/AutoGen/DeepAgents ~2min) ---"
|
||||
echo "--- Step 3: Wait for agents (OpenClaw ~3min, Hermes may take longer) ---"
|
||||
|
||||
wait_online "$ALICE" "Alice-Claude" 20 && check "Alice online" "ok" "ok" || check "Alice online" "online" "timeout"
|
||||
wait_online "$BOB" "Bob-LangGraph" 60 && check "Bob online" "ok" "ok" || check "Bob online" "online" "timeout"
|
||||
wait_online "$DAVE" "Dave-DeepAgents" 120 && check "Dave online" "ok" "ok" || check "Dave online" "online" "timeout"
|
||||
wait_online "$EVE" "Eve-CrewAI" 120 && check "Eve online" "ok" "ok" || check "Eve online" "online" "timeout"
|
||||
wait_online "$FRANK" "Frank-AutoGen" 120 && check "Frank online" "ok" "ok" || check "Frank online" "online" "timeout"
|
||||
wait_online "$BOB" "Bob-Codex" 60 && check "Bob online" "ok" "ok" || check "Bob online" "online" "timeout"
|
||||
wait_online "$DAVE" "Dave-Hermes" 180 && check "Dave online" "ok" "ok" || check "Dave online" "online" "timeout"
|
||||
wait_online "$CAROL" "Carol-OpenClaw" 360 && check "Carol online" "ok" "ok" || check "Carol online" "online" "timeout"
|
||||
|
||||
# --- Test A2A messages ---
|
||||
@@ -123,7 +111,7 @@ RESP=$(a2a_send "$ALICE" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Alice responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Bob (LangGraph)..."
|
||||
echo " Talking to Bob (Codex)..."
|
||||
RESP=$(a2a_send "$BOB" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Bob responds" "hello" "$RESP"
|
||||
@@ -133,21 +121,11 @@ RESP=$(a2a_send "$CAROL" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Carol responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Dave (DeepAgents)..."
|
||||
echo " Talking to Dave (Hermes)..."
|
||||
RESP=$(a2a_send "$DAVE" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Dave responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Eve (CrewAI)..."
|
||||
RESP=$(a2a_send "$EVE" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Eve responds" "hello" "$RESP"
|
||||
|
||||
echo " Talking to Frank (AutoGen)..."
|
||||
RESP=$(a2a_send "$FRANK" "say hello in one word")
|
||||
echo " -> $RESP"
|
||||
check "Frank responds" "hello" "$RESP"
|
||||
|
||||
# --- Peer discovery ---
|
||||
echo ""
|
||||
echo "--- Step 5: Peer discovery ---"
|
||||
@@ -157,7 +135,7 @@ peers = json.load(sys.stdin)
|
||||
print(f'{len(peers)} peers: {\" \".join(p.get(\"name\",\"\") for p in peers)}')
|
||||
" 2>/dev/null)
|
||||
echo " Alice sees: $R"
|
||||
check "Alice sees 5 peers" "5 peers" "$R"
|
||||
check "Alice sees 3 peers" "3 peers" "$R"
|
||||
|
||||
# --- Isolation ---
|
||||
echo ""
|
||||
@@ -168,7 +146,7 @@ check "No ws-* dirs on host" "0" "$HOST_WS"
|
||||
# --- Cleanup ---
|
||||
echo ""
|
||||
echo "--- Step 7: Cleanup ---"
|
||||
for ID in $ALICE $BOB $CAROL $DAVE $EVE $FRANK; do
|
||||
for ID in $ALICE $BOB $CAROL $DAVE; do
|
||||
curl -s -X DELETE "$PLATFORM/workspaces/$ID" > /dev/null 2>&1
|
||||
done
|
||||
check "Cleanup" "ok" "ok"
|
||||
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# EC2 leak check for staging E2E harnesses.
|
||||
#
|
||||
# Modes:
|
||||
# E2E_AWS_LEAK_CHECK=off skip
|
||||
# E2E_AWS_LEAK_CHECK=auto check only when aws + credentials exist
|
||||
# E2E_AWS_LEAK_CHECK=required fail if aws + credentials are unavailable
|
||||
#
|
||||
# Optional:
|
||||
# E2E_AWS_LEAK_CHECK_SECS poll budget, default 90
|
||||
# E2E_AWS_LEAK_CHECK_INTERVAL poll interval, default 10
|
||||
# E2E_AWS_TERMINATE_LEAKS=1 terminate matching leaked instances
|
||||
|
||||
e2e_aws_leak_mode() {
|
||||
echo "${E2E_AWS_LEAK_CHECK:-auto}"
|
||||
}
|
||||
|
||||
e2e_aws_region() {
|
||||
echo "${E2E_AWS_REGION:-${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-2}}}"
|
||||
}
|
||||
|
||||
e2e_aws_creds_available() {
|
||||
command -v aws >/dev/null 2>&1 || return 1
|
||||
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || return 1
|
||||
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || return 1
|
||||
}
|
||||
|
||||
e2e_ec2_instances_for_slug() {
|
||||
local slug="$1"
|
||||
local region
|
||||
region=$(e2e_aws_region)
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
aws ec2 describe-instances \
|
||||
--region "$region" \
|
||||
--filters "Name=tag:Name,Values=*$slug*" \
|
||||
"Name=instance-state-name,Values=pending,running,stopping,stopped" \
|
||||
--query 'Reservations[].Instances[].[InstanceId,State.Name,Tags[?Key==`Name`].Value|[0]]' \
|
||||
--output text
|
||||
}
|
||||
|
||||
e2e_terminate_instances() {
|
||||
local ids="$1"
|
||||
local region
|
||||
region=$(e2e_aws_region)
|
||||
|
||||
[ -n "$ids" ] || return 0
|
||||
# shellcheck disable=SC2086
|
||||
aws ec2 terminate-instances --region "$region" --instance-ids $ids >/dev/null
|
||||
}
|
||||
|
||||
e2e_verify_no_ec2_leaks_for_slug() {
|
||||
local slug="$1"
|
||||
local mode
|
||||
local max_secs
|
||||
local interval
|
||||
local elapsed=0
|
||||
local rows=""
|
||||
local ids=""
|
||||
|
||||
mode=$(e2e_aws_leak_mode)
|
||||
case "$mode" in
|
||||
off)
|
||||
echo "[aws-leak-check] skipped: E2E_AWS_LEAK_CHECK=off" >&2
|
||||
return 0
|
||||
;;
|
||||
auto|required) ;;
|
||||
*)
|
||||
echo "[aws-leak-check] invalid E2E_AWS_LEAK_CHECK=$mode (expected off|auto|required)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! e2e_aws_creds_available; then
|
||||
if [ "$mode" = "required" ]; then
|
||||
echo "[aws-leak-check] required but aws CLI or AWS credentials are unavailable" >&2
|
||||
return 2
|
||||
fi
|
||||
echo "[aws-leak-check] skipped: aws CLI or AWS credentials unavailable" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
max_secs="${E2E_AWS_LEAK_CHECK_SECS:-90}"
|
||||
interval="${E2E_AWS_LEAK_CHECK_INTERVAL:-10}"
|
||||
|
||||
while true; do
|
||||
rows=$(e2e_ec2_instances_for_slug "$slug" 2>&1) || {
|
||||
echo "[aws-leak-check] aws ec2 describe-instances failed for slug=$slug" >&2
|
||||
echo "$rows" >&2
|
||||
return 2
|
||||
}
|
||||
|
||||
if [ -z "$rows" ] || [ "$rows" = "None" ]; then
|
||||
echo "[aws-leak-check] no live EC2 instances for slug=$slug" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$elapsed" -ge "$max_secs" ]; then
|
||||
echo "[aws-leak-check] leaked EC2 instance(s) for slug=$slug after ${elapsed}s:" >&2
|
||||
echo "$rows" >&2
|
||||
if [ "${E2E_AWS_TERMINATE_LEAKS:-0}" = "1" ]; then
|
||||
ids=$(echo "$rows" | awk 'NF {print $1}' | sort -u | tr '\n' ' ')
|
||||
echo "[aws-leak-check] terminating leaked EC2 instance(s): $ids" >&2
|
||||
e2e_terminate_instances "$ids" || {
|
||||
echo "[aws-leak-check] terminate-instances failed for: $ids" >&2
|
||||
return 4
|
||||
}
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
}
|
||||
+20
-15
@@ -10,20 +10,18 @@
|
||||
# "gpt-4o" falls through to Anthropic
|
||||
# default + 401, see PR #1714.)
|
||||
#
|
||||
# langgraph → "openai:gpt-4o" (colon-form: langchain init_chat_model
|
||||
# requires "<provider>:<model>".
|
||||
# Slash-form was misinterpreted as
|
||||
# OpenRouter routing → fell through
|
||||
# without auth, surfaced 2026-05-03
|
||||
# after the a2a-sdk v1 contract bugs
|
||||
# PR #2558+#2563+#2567 cleared the
|
||||
# masking layers.)
|
||||
# claude-code → auth-aware:
|
||||
# E2E_MINIMAX_API_KEY → "MiniMax-M2"
|
||||
# E2E_ANTHROPIC_API_KEY → "claude-sonnet-4-6"
|
||||
# otherwise → "sonnet"
|
||||
#
|
||||
# claude-code → "sonnet" (entry-id form: claude-code template's
|
||||
# config.yaml uses bare model names,
|
||||
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
|
||||
# or ANTHROPIC_API_KEY rather than the
|
||||
# slug.)
|
||||
# claude-code provider routing is model-driven. The bare
|
||||
# "sonnet" alias selects the OAuth provider, so it is only a
|
||||
# good default when the canary is using Claude Code OAuth or
|
||||
# intentionally exercising the missing-auth path. MiniMax and
|
||||
# direct Anthropic API keys need model IDs that resolve to
|
||||
# their provider entries, otherwise the workspace boots
|
||||
# reachable but the first A2A call hits the wrong auth path.
|
||||
#
|
||||
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
|
||||
# operator dispatches the workflow to test a specific slug.
|
||||
@@ -44,8 +42,15 @@ pick_model_slug() {
|
||||
fi
|
||||
case "$runtime" in
|
||||
hermes) printf 'openai/gpt-4o' ;;
|
||||
langgraph) printf 'openai:gpt-4o' ;;
|
||||
claude-code) printf 'sonnet' ;;
|
||||
claude-code)
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
printf 'MiniMax-M2'
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
printf 'claude-sonnet-4-6'
|
||||
else
|
||||
printf 'sonnet'
|
||||
fi
|
||||
;;
|
||||
*) printf 'openai/gpt-4o' ;; # safest fallback (matches hermes)
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
# lint_cleanup_traps.sh — regression gate for the OSS-shape program's
|
||||
# "all E2E tests must have proper cleanup" bar (RFC #2873).
|
||||
#
|
||||
# Asserts: every shell file under tests/e2e/ that calls `mktemp` ALSO
|
||||
# installs an `EXIT` trap somewhere in the file. The trap is the
|
||||
# minimum-viable guarantee that scratch files won't leak when an
|
||||
# assertion or curl exits the script non-zero.
|
||||
# Asserts:
|
||||
# 1. every shell file under tests/e2e/ that calls `mktemp` ALSO
|
||||
# installs an `EXIT` trap somewhere in the file.
|
||||
# 2. every staging tenant E2E script that provisions a real org uses a
|
||||
# slug prefix caught by sweep-stale-e2e-orgs.yml and installs an
|
||||
# EXIT trap.
|
||||
#
|
||||
# These are the minimum-viable guarantees that scratch files and real
|
||||
# staging EC2 tenants converge back to zero when an assertion or curl
|
||||
# exits the script non-zero.
|
||||
#
|
||||
# Why this lints (instead of the test runner enforcing): shell scripts
|
||||
# can't easily be wrapped by an outer harness without breaking the
|
||||
@@ -21,6 +27,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
repo_root="$(cd ../.. && pwd)"
|
||||
|
||||
violations=0
|
||||
for f in test_*.sh; do
|
||||
@@ -32,6 +39,76 @@ for f in test_*.sh; do
|
||||
fi
|
||||
done
|
||||
|
||||
if ! python3 - "$repo_root" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo = Path(sys.argv[1])
|
||||
e2e_dir = repo / "tests" / "e2e"
|
||||
sweeper = repo / ".gitea" / "workflows" / "sweep-stale-e2e-orgs.yml"
|
||||
|
||||
errors: list[str] = []
|
||||
sweeper_text = sweeper.read_text()
|
||||
|
||||
required_sweeper_prefixes = ('"e2e-"', '"rt-e2e-"')
|
||||
for prefix in required_sweeper_prefixes:
|
||||
if prefix not in sweeper_text:
|
||||
errors.append(
|
||||
f"::error file=.gitea/workflows/sweep-stale-e2e-orgs.yml::"
|
||||
f"missing stale-org sweeper prefix {prefix}"
|
||||
)
|
||||
|
||||
slug_assignment_re = re.compile(r'^\s*SLUG=(["\'])(?P<value>.+?)\1', re.MULTILINE)
|
||||
covered_prefixes = ("e2e-", "rt-e2e-")
|
||||
|
||||
for path in sorted(e2e_dir.glob("test_*staging*.sh")):
|
||||
text = path.read_text()
|
||||
creates_org = "/cp/admin/orgs" in text and re.search(r"\bPOST\b", text)
|
||||
deletes_org = "/cp/admin/tenants" in text and re.search(r"\bDELETE\b", text)
|
||||
if not (creates_org or deletes_org):
|
||||
continue
|
||||
|
||||
rel = path.relative_to(repo)
|
||||
if not re.search(r"trap\s+.*\bEXIT\b", text):
|
||||
errors.append(
|
||||
f"::error file={rel}::staging tenant E2E touches CP org lifecycle "
|
||||
"but has no EXIT trap for teardown"
|
||||
)
|
||||
|
||||
assignments = [m.group("value") for m in slug_assignment_re.finditer(text)]
|
||||
if not assignments:
|
||||
errors.append(
|
||||
f"::error file={rel}::staging tenant E2E touches CP org lifecycle "
|
||||
"but has no quoted SLUG=... assignment for scoped cleanup"
|
||||
)
|
||||
continue
|
||||
|
||||
for value in assignments:
|
||||
literal_prefix = re.split(r"[$`]", value, maxsplit=1)[0]
|
||||
if not literal_prefix:
|
||||
errors.append(
|
||||
f"::error file={rel}::SLUG assignment starts with dynamic data "
|
||||
f"({value!r}); use a fixed e2e-* or rt-e2e-* prefix so "
|
||||
"sweep-stale-e2e-orgs can reap orphans"
|
||||
)
|
||||
continue
|
||||
if not literal_prefix.startswith(covered_prefixes):
|
||||
errors.append(
|
||||
f"::error file={rel}::SLUG prefix {literal_prefix!r} is not "
|
||||
"covered by sweep-stale-e2e-orgs.yml; use e2e-* or rt-e2e-*"
|
||||
)
|
||||
|
||||
if errors:
|
||||
print("\n".join(errors))
|
||||
raise SystemExit(1)
|
||||
|
||||
print("✓ staging tenant E2E slug prefixes are covered by sweep-stale-e2e-orgs and use EXIT traps")
|
||||
PY
|
||||
then
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
if [ "$violations" -gt 0 ]; then
|
||||
echo "::error::$violations shell E2E file(s) leak scratch on early exit. See above."
|
||||
exit 1
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/aws_leak_check.sh
|
||||
source "$SCRIPT_DIR/lib/aws_leak_check.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
TMPDIR_E2E=$(mktemp -d -t aws-leak-check-e2e-XXXXXX)
|
||||
trap 'rm -rf "$TMPDIR_E2E"' EXIT INT TERM
|
||||
|
||||
make_fake_aws() {
|
||||
local body="$1"
|
||||
mkdir -p "$TMPDIR_E2E/bin"
|
||||
cat > "$TMPDIR_E2E/bin/aws" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "\$*" >> "$TMPDIR_E2E/aws.calls"
|
||||
$body
|
||||
EOF
|
||||
chmod +x "$TMPDIR_E2E/bin/aws"
|
||||
}
|
||||
|
||||
reset_env() {
|
||||
/bin/rm -f "$TMPDIR_E2E/aws.calls"
|
||||
export PATH="$TMPDIR_E2E/bin:$ORIG_PATH"
|
||||
export AWS_ACCESS_KEY_ID=test-access
|
||||
export AWS_SECRET_ACCESS_KEY=test-secret
|
||||
export AWS_DEFAULT_REGION=us-east-2
|
||||
export E2E_AWS_LEAK_CHECK=required
|
||||
export E2E_AWS_LEAK_CHECK_SECS=0
|
||||
export E2E_AWS_LEAK_CHECK_INTERVAL=1
|
||||
unset E2E_AWS_TERMINATE_LEAKS
|
||||
}
|
||||
|
||||
assert_rc() {
|
||||
local label="$1"
|
||||
local expected="$2"
|
||||
shift 2
|
||||
local observed
|
||||
"$@" >/tmp/aws-leak-check.out 2>/tmp/aws-leak-check.err
|
||||
observed=$?
|
||||
if [ "$observed" = "$expected" ]; then
|
||||
echo " PASS $label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $label: expected rc=$expected observed=$observed" >&2
|
||||
echo " stderr:" >&2
|
||||
sed 's/^/ /' /tmp/aws-leak-check.err >&2
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
ORIG_PATH="$PATH"
|
||||
|
||||
echo "Test: AWS EC2 leak check helper"
|
||||
|
||||
reset_env
|
||||
/bin/rm -rf "${TMPDIR_E2E:?}/bin"
|
||||
/bin/mkdir -p "$TMPDIR_E2E/noaws"
|
||||
export PATH="$TMPDIR_E2E/noaws"
|
||||
export E2E_AWS_LEAK_CHECK=auto
|
||||
assert_rc "auto mode skips when aws is unavailable" 0 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
|
||||
|
||||
reset_env
|
||||
/bin/rm -rf "${TMPDIR_E2E:?}/bin"
|
||||
/bin/mkdir -p "$TMPDIR_E2E/noaws"
|
||||
export PATH="$TMPDIR_E2E/noaws"
|
||||
export E2E_AWS_LEAK_CHECK=required
|
||||
assert_rc "required mode fails when aws is unavailable" 2 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
|
||||
|
||||
reset_env
|
||||
# shellcheck disable=SC2016
|
||||
make_fake_aws 'if [ "$1 $2" = "ec2 describe-instances" ]; then exit 0; fi'
|
||||
assert_rc "no matching EC2 returns clean" 0 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
|
||||
|
||||
reset_env
|
||||
# shellcheck disable=SC2016
|
||||
make_fake_aws 'if [ "$1 $2" = "ec2 describe-instances" ]; then echo "i-123 running ws-tenant-e2e-smoke-test-abc"; exit 0; fi'
|
||||
assert_rc "persistent matching EC2 is a leak" 4 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
|
||||
|
||||
reset_env
|
||||
export E2E_AWS_TERMINATE_LEAKS=1
|
||||
# shellcheck disable=SC2016
|
||||
make_fake_aws '
|
||||
if [ "$1 $2" = "ec2 describe-instances" ]; then
|
||||
echo "i-123 running ws-tenant-e2e-smoke-test-abc"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1 $2" = "ec2 terminate-instances" ]; then
|
||||
echo "terminated" >/dev/null
|
||||
exit 0
|
||||
fi
|
||||
'
|
||||
assert_rc "terminate mode attempts cleanup before returning leak" 4 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
|
||||
if grep -q "terminate-instances" "$TMPDIR_E2E/aws.calls"; then
|
||||
echo " PASS terminate-instances was called"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL terminate-instances was not called" >&2
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "passed=$PASS failed=$FAIL"
|
||||
[ "$FAIL" = "0" ]
|
||||
@@ -15,7 +15,7 @@
|
||||
# Required env:
|
||||
# BASE default http://localhost:8080
|
||||
# override to https://<id>.<tenant>.staging...
|
||||
# WORKSPACE_RUNTIME default langgraph (any internal runtime)
|
||||
# WORKSPACE_RUNTIME default claude-code (any maintained internal runtime)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 upload + read-back round-trip succeeded
|
||||
@@ -26,7 +26,7 @@
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${BASE:-http://localhost:8080}"
|
||||
RUNTIME="${WORKSPACE_RUNTIME:-langgraph}"
|
||||
RUNTIME="${WORKSPACE_RUNTIME:-claude-code}"
|
||||
|
||||
PARENT=""
|
||||
PARENT_TOK=""
|
||||
@@ -49,7 +49,7 @@ trap cleanup EXIT INT TERM
|
||||
echo "[1/5] POST /workspaces (runtime=$RUNTIME)..."
|
||||
P_RESP=$(curl -sS -X POST "$BASE/workspaces" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"e2e-chat-upload\",\"runtime\":\"$RUNTIME\",\"tier\":2}")
|
||||
-d "{\"name\":\"e2e-chat-upload\",\"runtime\":\"$RUNTIME\",\"tier\":2,\"model\":\"sonnet\"}")
|
||||
PARENT=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$PARENT" ] || { echo " ✗ workspace create failed: $P_RESP"; exit 1; }
|
||||
echo " ✓ workspace=$PARENT"
|
||||
|
||||
@@ -50,13 +50,16 @@ docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
|
||||
echo ""
|
||||
echo "--- Create Workspaces ---"
|
||||
|
||||
# model is required at the Create boundary (CTO 2026-05-22 SSOT —
|
||||
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
|
||||
# Pass the same value the deleted DefaultModel("claude-code") returned.
|
||||
ROOT=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
|
||||
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","tier":3}' \
|
||||
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","tier":3}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check_contains "Create root workspace" "-" "$ROOT"
|
||||
|
||||
CHILD=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
|
||||
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
check_contains "Create child workspace" "-" "$CHILD"
|
||||
|
||||
|
||||
@@ -137,14 +137,14 @@ check "Create claude-code workspace" '"status":"provisioning"' "$R"
|
||||
RT_CC_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT LangGraph","role":"Test","tier":2,"runtime":"langgraph"}')
|
||||
check "Create langgraph workspace" '"status":"provisioning"' "$R"
|
||||
RT_LG_ID=$(echo "$R" | jq_extract "['id']")
|
||||
-d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex"}')
|
||||
check "Create codex workspace" '"status":"provisioning"' "$R"
|
||||
RT_CX_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT CrewAI","role":"Test","tier":2,"runtime":"crewai"}')
|
||||
check "Create crewai workspace" '"status":"provisioning"' "$R"
|
||||
RT_CR_ID=$(echo "$R" | jq_extract "['id']")
|
||||
-d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes"}')
|
||||
check "Create hermes workspace" '"status":"provisioning"' "$R"
|
||||
RT_HM_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
# Wait for containers to start (poll up to 30s for first one to appear)
|
||||
if command -v docker &>/dev/null; then
|
||||
@@ -174,8 +174,8 @@ if command -v docker &>/dev/null; then
|
||||
}
|
||||
|
||||
_check_image "$RT_CC_ID" "claude-code" "claude-code uses claude-code image"
|
||||
_check_image "$RT_LG_ID" "langgraph" "langgraph uses langgraph image"
|
||||
_check_image "$RT_CR_ID" "crewai" "crewai uses crewai image"
|
||||
_check_image "$RT_CX_ID" "codex" "codex uses codex image"
|
||||
_check_image "$RT_HM_ID" "hermes" "hermes uses hermes image"
|
||||
else
|
||||
echo " SKIP: Docker not available — cannot verify container images"
|
||||
SKIP=$((SKIP + 3))
|
||||
@@ -183,7 +183,7 @@ fi
|
||||
|
||||
# Verify runtime in agent card after registration
|
||||
sleep 5
|
||||
for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do
|
||||
for rt_id in $RT_CC_ID $RT_CX_ID $RT_HM_ID; do
|
||||
# Register so we can check agent card
|
||||
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$rt_id\",\"url\":\"http://localhost:19999\",\"agent_card\":{\"name\":\"Test\",\"skills\":[]}}" > /dev/null 2>&1
|
||||
@@ -204,20 +204,20 @@ fi
|
||||
|
||||
# Verify runtime change persists on restart (if provisioner supports ExecRead)
|
||||
# Write a new runtime to config, restart, check image changes
|
||||
R=$(curl -s -X PUT "$BASE/workspaces/$RT_LG_ID/files/config.yaml" \
|
||||
R=$(curl -s -X PUT "$BASE/workspaces/$RT_CX_ID/files/config.yaml" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":"name: RT LangGraph\nruntime: deepagents\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}')
|
||||
-d '{"content":"name: RT Codex\nruntime: openclaw\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}')
|
||||
if echo "$R" | grep -qF "saved"; then
|
||||
curl -s -X POST "$BASE/workspaces/$RT_LG_ID/restart" > /dev/null 2>&1
|
||||
curl -s -X POST "$BASE/workspaces/$RT_CX_ID/restart" > /dev/null 2>&1
|
||||
# Poll up to 30s for the new container image to appear (restart can take a while)
|
||||
if command -v docker &>/dev/null; then
|
||||
short_id="${RT_LG_ID:0:12}"
|
||||
short_id="${RT_CX_ID:0:12}"
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
actual=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "")
|
||||
if echo "$actual" | grep -qF "deepagents"; then break; fi
|
||||
if echo "$actual" | grep -qF "openclaw"; then break; fi
|
||||
done
|
||||
_check_image "$RT_LG_ID" "deepagents" "Runtime change langgraph→deepagents on restart"
|
||||
_check_image "$RT_CX_ID" "openclaw" "Runtime change codex to openclaw on restart"
|
||||
else
|
||||
echo " SKIP: Docker not available"
|
||||
SKIP=$((SKIP + 1))
|
||||
@@ -228,7 +228,7 @@ else
|
||||
fi
|
||||
|
||||
# Clean up runtime test workspaces
|
||||
for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do
|
||||
for rt_id in $RT_CC_ID $RT_CX_ID $RT_HM_ID; do
|
||||
curl -s -X DELETE "$BASE/workspaces/$rt_id?confirm=true" > /dev/null 2>&1
|
||||
sleep 0.3
|
||||
done
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
# Regression test for tests/e2e/lib/model_slug.sh.
|
||||
#
|
||||
# PR #2571 fixed a synth-E2E masking bug where MODEL_SLUG was hardcoded
|
||||
# to "openai/gpt-4o" (slash-form) but langgraph's init_chat_model needs
|
||||
# "openai:gpt-4o" (colon-form). Fix shipped as a per-runtime case
|
||||
# statement. Without this regression test, dropping any branch of the
|
||||
# case (or flipping a slug format) would silently revert behavior — the
|
||||
# E2E only fails as "Could not resolve authentication method" at the
|
||||
# very first message, after a successful tenant + workspace provision.
|
||||
# to "openai/gpt-4o" (slash-form). Without this regression test, dropping
|
||||
# any branch of the case (or flipping a slug format) would silently revert
|
||||
# behavior — the E2E only fails as "Could not resolve authentication method"
|
||||
# at the very first message, after a successful tenant + workspace provision.
|
||||
#
|
||||
# Each branch must FAIL the test if the dispatch behavior changes, not
|
||||
# just produce some non-empty string.
|
||||
@@ -16,7 +14,7 @@ set -uo pipefail
|
||||
# Resolve to the lib relative to this test file so the test runs from
|
||||
# any cwd (CI, local invocation, repo root).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/model_slug.sh
|
||||
# shellcheck source=tests/e2e/lib/model_slug.sh
|
||||
source "$SCRIPT_DIR/lib/model_slug.sh"
|
||||
|
||||
PASS=0
|
||||
@@ -47,8 +45,17 @@ echo
|
||||
|
||||
# ── Per-runtime branches (the load-bearing ones for synth-E2E) ──
|
||||
run_test "hermes → slash-form (derive-provider.sh contract)" hermes "openai/gpt-4o"
|
||||
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "openai:gpt-4o"
|
||||
run_test "claude-code → bare model name (entry-id form)" claude-code "sonnet"
|
||||
run_test "codex → slash-form fallback" codex "openai/gpt-4o"
|
||||
run_test "claude-code → OAuth/default alias" claude-code "sonnet"
|
||||
|
||||
got=$(unset E2E_MODEL_SLUG E2E_ANTHROPIC_API_KEY; E2E_MINIMAX_API_KEY="mx-test" pick_model_slug claude-code)
|
||||
assert_eq "claude-code + MiniMax key → MiniMax model" "$got" "MiniMax-M2"
|
||||
|
||||
got=$(unset E2E_MODEL_SLUG E2E_MINIMAX_API_KEY; E2E_ANTHROPIC_API_KEY="sk-ant-test" pick_model_slug claude-code)
|
||||
assert_eq "claude-code + Anthropic API key → Anthropic API model" "$got" "claude-sonnet-4-6"
|
||||
|
||||
got=$(unset E2E_MODEL_SLUG; E2E_MINIMAX_API_KEY="mx-priority" E2E_ANTHROPIC_API_KEY="sk-ant-loser" pick_model_slug claude-code)
|
||||
assert_eq "claude-code + both keys → MiniMax priority" "$got" "MiniMax-M2"
|
||||
|
||||
# ── Fallback for unknown runtime ──
|
||||
# Picks slash-form (hermes-shaped) since hermes is the historical
|
||||
@@ -65,8 +72,8 @@ echo
|
||||
echo "Test: pick_model_slug — E2E_MODEL_SLUG override"
|
||||
echo
|
||||
|
||||
got=$(E2E_MODEL_SLUG="anthropic:claude-opus-4-7" pick_model_slug langgraph)
|
||||
assert_eq "override beats langgraph default" "$got" "anthropic:claude-opus-4-7"
|
||||
got=$(E2E_MODEL_SLUG="anthropic:claude-opus-4-7" pick_model_slug codex)
|
||||
assert_eq "override beats codex default" "$got" "anthropic:claude-opus-4-7"
|
||||
|
||||
got=$(E2E_MODEL_SLUG="custom/whatever" pick_model_slug hermes)
|
||||
assert_eq "override beats hermes default" "$got" "custom/whatever"
|
||||
@@ -79,8 +86,8 @@ assert_eq "override beats claude-code default" "$got" "some-b
|
||||
# it because changing this behavior (e.g. via -v test) would silently
|
||||
# break the dispatch when an operator passes "" to clear an inherited
|
||||
# env var.
|
||||
got=$(E2E_MODEL_SLUG="" pick_model_slug langgraph)
|
||||
assert_eq "empty-string override falls through to dispatch" "$got" "openai:gpt-4o"
|
||||
got=$(E2E_MODEL_SLUG="" pick_model_slug codex)
|
||||
assert_eq "empty-string override falls through to dispatch" "$got" "openai/gpt-4o"
|
||||
|
||||
echo
|
||||
echo "─────────────────────────────────────────────────"
|
||||
|
||||
@@ -92,8 +92,12 @@ for _wid in $PRIOR; do
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
||||
done
|
||||
|
||||
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
|
||||
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
|
||||
# Body has no runtime → defaults to claude-code; pass the matching model
|
||||
# that the workspace-creation contract now requires.
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Notify E2E","tier":1}')
|
||||
-d '{"name":"Notify E2E","tier":1,"model":"sonnet"}')
|
||||
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
|
||||
echo "Created workspace $WSID"
|
||||
|
||||
@@ -24,14 +24,12 @@
|
||||
#
|
||||
# Only PROVISIONING differs from staging:
|
||||
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
|
||||
# token + each workspace's MCP bearer from create response or an admin
|
||||
# token-mint fallback.
|
||||
# token + each workspace's MCP bearer from the POST /workspaces
|
||||
# create response.
|
||||
# - local: POST /workspaces directly against the local stack
|
||||
# (BASE, default http://localhost:8080), MCP bearer minted via
|
||||
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
|
||||
# deterministic, gated by MOLECULE_ENV != production). Same model
|
||||
# every other local E2E (test_priority_runtimes_e2e.sh,
|
||||
# test_api.sh) already uses; no new credential/provision flow.
|
||||
# (BASE, default http://localhost:8080), MCP bearer consumed inline
|
||||
# from the create response (auth_token field). Same model every
|
||||
# other local E2E uses; no new credential/provision flow.
|
||||
#
|
||||
# By default the local backend creates external-mode workspace rows and
|
||||
# drives the literal MCP path directly. That keeps the local peer-visibility
|
||||
@@ -81,6 +79,17 @@ NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
extract_auth_token() {
|
||||
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
|
||||
}
|
||||
|
||||
CREATED_WSIDS=()
|
||||
ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
|
||||
ADMIN_AUTH=()
|
||||
@@ -131,17 +140,6 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
|
||||
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# admin/test-token is the local MCP-bearer mint path; it 404s in
|
||||
# production. If it is off, this gate cannot drive the literal call.
|
||||
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 >/dev/null 2>&1; then
|
||||
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
|
||||
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
|
||||
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 2>/dev/null)
|
||||
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
|
||||
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ok " local stack healthy"
|
||||
|
||||
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
|
||||
@@ -241,9 +239,31 @@ else
|
||||
fi
|
||||
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
|
||||
|
||||
# Map runtime → model per the CTO 2026-05-22 SSOT directive (model is
|
||||
# required, no platform default). External runtimes are exempt by the
|
||||
# Create-handler gate — for them the URL is the contract — but we still
|
||||
# pass model="external:custom" defensively in case a downstream consumer
|
||||
# of the create body asserts presence.
|
||||
_model_for_runtime() {
|
||||
case "$1" in
|
||||
claude-code) echo "sonnet" ;;
|
||||
codex) echo "gpt-5.5" ;;
|
||||
kimi) echo "kimi-coding/kimi-k2-coding-6" ;;
|
||||
minimax) echo "minimax/MiniMax-M2.7" ;;
|
||||
external) echo "external:custom" ;;
|
||||
*) echo "anthropic:claude-opus-4-7" ;;
|
||||
esac
|
||||
}
|
||||
PARENT_MODEL=$(_model_for_runtime "$PARENT_RUNTIME")
|
||||
P_RESP=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
|
||||
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
|
||||
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
# PARENT_TOKEN captured for symmetry with the per-sibling auth-token
|
||||
# capture in the runtime loop below + reserved for follow-up steps
|
||||
# that need parent-side auth. Current downstream steps reach the parent
|
||||
# via admin token, so the variable isn't dereferenced — SC2034.
|
||||
# shellcheck disable=SC2034 # captured for downstream parent-auth use; see #1644 follow-up
|
||||
PARENT_TOKEN=$(echo "$P_RESP" | extract_auth_token)
|
||||
if [ -z "$PARENT_ID" ]; then
|
||||
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
|
||||
exit 1
|
||||
@@ -259,6 +279,8 @@ log " PARENT_ID=$PARENT_ID runtime=$PARENT_RUNTIME"
|
||||
WS_IDS_MAP=""
|
||||
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
|
||||
VERDICT_MAP=""
|
||||
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
|
||||
WS_TOKENS_MAP=""
|
||||
_map_set() { # _map_set <mapvarname> <key> <value>
|
||||
local __m="$1" __k="$2" __v="$3" __cur
|
||||
eval "__cur=\$$__m"
|
||||
@@ -291,14 +313,21 @@ for rt in $PV_RUNTIMES; do
|
||||
CREATE_RUNTIME="$rt"
|
||||
CREATE_EXTRA=""
|
||||
fi
|
||||
CREATE_MODEL=$(_model_for_runtime "$CREATE_RUNTIME")
|
||||
R=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
|
||||
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
|
||||
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
WTOK=$(echo "$R" | extract_auth_token)
|
||||
if [ -z "$WID" ]; then
|
||||
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "::error::$rt workspace create did not return an auth_token — cannot drive the literal MCP call" >&2
|
||||
exit 1
|
||||
fi
|
||||
_map_set WS_IDS_MAP "$rt" "$WID"
|
||||
_map_set WS_TOKENS_MAP "$rt" "$WTOK"
|
||||
CREATED_WSIDS+=("$WID")
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
|
||||
@@ -356,10 +385,10 @@ log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
|
||||
echo ""
|
||||
for rt in $ONLINE_RUNTIMES; do
|
||||
wid="$(_map_get WS_IDS_MAP "$rt")"
|
||||
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
|
||||
WTOK="$(_map_get WS_TOKENS_MAP "$rt")"
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " ✗ $rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
|
||||
echo " ✗ $rt: workspace create did not return an auth_token — cannot drive the literal call"
|
||||
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
|
||||
REGRESSED=1
|
||||
echo ""
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
# 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 drives its
|
||||
# MCP call. External-like runtimes may return the token in POST
|
||||
# /workspaces; managed container runtimes usually require the admin token
|
||||
# mint fallback below.
|
||||
# tenant workspace creation; each workspace's OWN auth_token is consumed
|
||||
# inline from the POST /workspaces 201 response to drive its MCP call.
|
||||
# No dev-only admin token-mint routes are used in this E2E
|
||||
# (feedback_no_dev_only_routes_in_e2e).
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
@@ -54,6 +54,9 @@
|
||||
# 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
|
||||
# PV_TOKEN_DIAGNOSTIC_ONLY
|
||||
# 1 -> stop after create/token acquisition. Useful
|
||||
# to classify Hermes-only vs shared auth-route issues.
|
||||
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
|
||||
#
|
||||
# Exit codes:
|
||||
@@ -232,6 +235,12 @@ 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
|
||||
BUILDINFO=$(curl -fsS "$TENANT_URL/buildinfo" -m 10 2>/dev/null || true)
|
||||
if [ -n "$BUILDINFO" ]; then
|
||||
log " tenant buildinfo: $(echo "$BUILDINFO" | head -c 300)"
|
||||
else
|
||||
log " tenant buildinfo unavailable"
|
||||
fi
|
||||
|
||||
# ─── 4. Provision the parent + one sibling per runtime under test ──────
|
||||
# Inject the LLM provider key so each runtime can authenticate at boot.
|
||||
@@ -260,37 +269,20 @@ 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)
|
||||
# External-like runtimes may return connection.auth_token on create.
|
||||
# Managed container runtimes usually return only id/status here, then
|
||||
# receive their bearer through registry/bootstrap; for this literal MCP
|
||||
# driver we mint an admin test token below.
|
||||
WTOK=$(echo "$R" | extract_auth_token)
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
|
||||
TOKEN_DIAG=""
|
||||
if [ -z "$WTOK" ]; then
|
||||
TTOK_FILE=$(mktemp)
|
||||
TTOK_CODE=$(tenant_call_capture POST "/admin/workspaces/$WID/tokens" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
|
||||
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
|
||||
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
|
||||
TOKEN_DIAG="POST /admin/workspaces/$WID/tokens -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
|
||||
rm -f "$TTOK_FILE"
|
||||
fi
|
||||
if [ -z "$WTOK" ]; then
|
||||
TTOK_FILE=$(mktemp)
|
||||
TTOK_CODE=$(tenant_call_capture GET "/admin/workspaces/$WID/test-token" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
|
||||
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
|
||||
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
|
||||
TOKEN_DIAG="${TOKEN_DIAG}
|
||||
GET /admin/workspaces/$WID/test-token -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
|
||||
rm -f "$TTOK_FILE"
|
||||
fi
|
||||
[ -n "$WTOK" ] || fail "$rt workspace did not return or mint an auth_token — cannot drive its MCP call (create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
|
||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo \"$R\" | head -c 300)"
|
||||
[ -n "$WTOK" ] || fail "$rt workspace create did not return an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo \"$R\" | redact_token_body))"
|
||||
WS_IDS[$rt]="$WID"
|
||||
WS_TOKENS[$rt]="$WTOK"
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
log " $rt → $WID"
|
||||
done
|
||||
|
||||
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
|
||||
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── 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 ))
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Staging E2E diagnostic — classify peer-visibility token acquisition.
|
||||
#
|
||||
# This is intentionally narrower than test_peer_visibility_mcp_staging.sh:
|
||||
# it provisions the same throwaway org, creates managed sibling workspaces,
|
||||
# and stops immediately after auth_token acquisition. The default runtime set
|
||||
# compares hermes with claude-code so a failure is easy to classify:
|
||||
# - hermes fails, claude-code passes -> Hermes/runtime-specific
|
||||
# - both fail -> shared admin/auth/proxy route
|
||||
#
|
||||
# Required env matches test_peer_visibility_mcp_staging.sh:
|
||||
# MOLECULE_ADMIN_TOKEN
|
||||
# Optional:
|
||||
# MOLECULE_CP_URL, E2E_RUN_ID, PV_RUNTIMES, E2E_KEEP_ORG,
|
||||
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export PV_RUNTIMES="${PV_RUNTIMES:-hermes claude-code}"
|
||||
export PV_TOKEN_DIAGNOSTIC_ONLY=1
|
||||
|
||||
exec "$(dirname "${BASH_SOURCE[0]}")/test_peer_visibility_mcp_staging.sh"
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E test: every runtime template (8 total) works end-to-end.
|
||||
# E2E test: every maintained runtime template works end-to-end.
|
||||
#
|
||||
# Self-contained happy-path smoke per runtime. Provisions a fresh
|
||||
# workspace, waits for status=online, sends a real A2A message, and
|
||||
@@ -7,13 +7,12 @@
|
||||
# extraction (and ongoing template work) can't silently break any
|
||||
# runtime.
|
||||
#
|
||||
# Runtimes covered: claude-code, hermes, langgraph, crewai, autogen,
|
||||
# deepagents, openclaw, gemini-cli. claude-code + hermes have unique
|
||||
# Runtimes covered: claude-code, codex, hermes, openclaw.
|
||||
# claude-code + hermes have unique
|
||||
# provisioning quirks (claude-code OAuth, hermes 15-min cold-boot)
|
||||
# and stay first-class with their own run_<runtime> functions; the
|
||||
# 5 OpenAI-backed runtimes share run_openai_runtime; gemini-cli has
|
||||
# its own block (Google AI key). Each phase skips cleanly if its
|
||||
# prerequisite secret is missing.
|
||||
# OpenAI-backed runtimes share run_openai_runtime. Each phase skips cleanly
|
||||
# if its prerequisite secret is missing.
|
||||
#
|
||||
# What this proves:
|
||||
# 1. Provisioning + container boot works for each runtime.
|
||||
@@ -188,8 +187,9 @@ import json, os
|
||||
print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))
|
||||
")
|
||||
local resp wsid
|
||||
# model required (CTO 2026-05-22 SSOT) — pass the deleted DefaultModel("claude-code") value.
|
||||
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"tier\":1,\"secrets\":$secrets}")
|
||||
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":1,\"secrets\":$secrets}")
|
||||
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
|
||||
if [ -z "$wsid" ]; then
|
||||
fail "create claude-code workspace" "$resp"
|
||||
@@ -295,9 +295,8 @@ print(json.dumps({
|
||||
####################################################################
|
||||
# Secondary runtimes — same provision/online/A2A loop, parametrized.
|
||||
####################################################################
|
||||
# These 5 templates (langgraph, crewai, autogen, deepagents, openclaw)
|
||||
# all use OpenAI as their LLM provider in the default config and don't
|
||||
# need the hermes-specific HERMES_* secret block. Skip if no key.
|
||||
# Codex and OpenClaw use OpenAI as their LLM provider in this smoke and
|
||||
# don't need the hermes-specific HERMES_* secret block. Skip if no key.
|
||||
# claude-code + hermes stay first-class above because each has unique
|
||||
# provisioning quirks (claude-code OAuth, hermes cold-boot tolerance);
|
||||
# refactoring them into this generic loop would lose those guards.
|
||||
@@ -360,66 +359,17 @@ print(json.dumps({
|
||||
fi
|
||||
}
|
||||
|
||||
run_langgraph() { run_openai_runtime "langgraph" "langgraph"; }
|
||||
run_crewai() { run_openai_runtime "crewai" "crewai"; }
|
||||
run_autogen() { run_openai_runtime "autogen" "autogen"; }
|
||||
run_deepagents() { run_openai_runtime "deepagents" "deepagents"; }
|
||||
run_codex() { run_openai_runtime "codex" "codex"; }
|
||||
run_openclaw() { run_openai_runtime "openclaw" "openclaw"; }
|
||||
|
||||
# gemini-cli wants a Google API key, not OpenAI. Skip if absent.
|
||||
run_gemini_cli() {
|
||||
echo ""
|
||||
echo "=== gemini-cli happy path ==="
|
||||
if [ -z "${E2E_GEMINI_API_KEY:-}" ]; then
|
||||
skip "E2E_GEMINI_API_KEY not set (gemini-cli needs Google AI key)"
|
||||
return 0
|
||||
fi
|
||||
local secrets
|
||||
secrets=$(python3 -c "
|
||||
import json, os
|
||||
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
|
||||
")
|
||||
local resp wsid
|
||||
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"tier\":1,\"secrets\":$secrets}")
|
||||
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
|
||||
if [ -z "$wsid" ]; then fail "create gemini-cli workspace" "$resp"; return 0; fi
|
||||
CREATED_WSIDS+=("$wsid")
|
||||
echo " workspace=$wsid"
|
||||
local final
|
||||
final=$(wait_for_status "$wsid" "online failed" 240) || true
|
||||
if [ "$final" != "online" ]; then
|
||||
fail "gemini-cli workspace reaches online" "final status: $final"
|
||||
return 0
|
||||
fi
|
||||
pass "gemini-cli workspace reaches online"
|
||||
local token; token=$(e2e_mint_test_token "$wsid")
|
||||
if [ -z "$token" ]; then fail "mint gemini-cli test token" "no token"; return 0; fi
|
||||
local reply
|
||||
if reply=$(send_test_prompt "$wsid" "$token"); then
|
||||
if echo "$reply" | grep -q "PONG"; then
|
||||
pass "gemini-cli reply contains PONG"
|
||||
else
|
||||
pass "gemini-cli reply non-empty (first 80 chars: ${reply:0:80})"
|
||||
fi
|
||||
assert_activity_logged "gemini-cli" "$wsid" "$token"
|
||||
else
|
||||
fail "gemini-cli reply" "${reply:-<empty or error>}"
|
||||
fi
|
||||
}
|
||||
|
||||
WANT="${E2E_RUNTIMES:-claude-code hermes}"
|
||||
WANT="${E2E_RUNTIMES:-claude-code codex hermes openclaw}"
|
||||
for r in $WANT; do
|
||||
case "$r" in
|
||||
claude-code) run_claude_code ;;
|
||||
codex) run_codex ;;
|
||||
hermes) run_hermes ;;
|
||||
langgraph) run_langgraph ;;
|
||||
crewai) run_crewai ;;
|
||||
autogen) run_autogen ;;
|
||||
deepagents) run_deepagents ;;
|
||||
openclaw) run_openclaw ;;
|
||||
gemini-cli) run_gemini_cli ;;
|
||||
all) run_claude_code; run_hermes; run_langgraph; run_crewai; run_autogen; run_deepagents; run_openclaw; run_gemini_cli ;;
|
||||
all) run_claude_code; run_codex; run_hermes; run_openclaw ;;
|
||||
*) echo "unknown runtime in E2E_RUNTIMES: $r" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
# Optional env:
|
||||
# E2E_RUNTIME hermes (default) | claude-code | langgraph
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold EC2 budget)
|
||||
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS default 3600 (60 min — hermes
|
||||
# cold-boot worst-case + slack). Raised from
|
||||
# 1800 (#1646) because flaky tenant-provisioning
|
||||
# latency (not a code regression) causes
|
||||
# alternating pass/fail on identical SHAs.
|
||||
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
|
||||
# E2E_RUN_ID Slug suffix; CI: ${GITHUB_RUN_ID}
|
||||
# E2E_MODE full (default) | smoke
|
||||
@@ -32,6 +37,11 @@
|
||||
# mapped to `smoke` for back-compat with
|
||||
# any in-flight runner picking up an older
|
||||
# workflow checkout)
|
||||
# E2E_AWS_LEAK_CHECK auto (default) | required | off
|
||||
# required in CI so teardown cannot report
|
||||
# clean while slug-tagged EC2 remains alive
|
||||
# E2E_AWS_TERMINATE_LEAKS 1 → terminate slug-tagged leaked EC2 before
|
||||
# exiting 4
|
||||
# E2E_INTENTIONAL_FAILURE 1 → poison tenant token mid-run so the
|
||||
# script fails; the EXIT trap MUST still
|
||||
# tear down cleanly (and exit 4 on leak).
|
||||
@@ -51,6 +61,7 @@ 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}"
|
||||
RUNTIME="${E2E_RUNTIME:-hermes}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
|
||||
WORKSPACE_ONLINE_TIMEOUT_SECS="${E2E_WORKSPACE_ONLINE_TIMEOUT_SECS:-3600}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
MODE="${E2E_MODE:-full}"
|
||||
# `canary` is a legacy alias for `smoke` retained for back-compat with
|
||||
@@ -82,8 +93,12 @@ ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
# Per-runtime model slug dispatch — see lib/model_slug.sh for the rationale.
|
||||
# Extracted so unit tests (tests/e2e/test_model_slug.sh) can pin every branch
|
||||
# without booting the full 11-step lifecycle.
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/model_slug.sh
|
||||
source "$(dirname "$0")/lib/model_slug.sh"
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/aws_leak_check.sh
|
||||
source "$(dirname "$0")/lib/aws_leak_check.sh"
|
||||
|
||||
CURL_COMMON=(-sS --fail-with-body --max-time 30)
|
||||
|
||||
@@ -119,12 +134,14 @@ cleanup_org() {
|
||||
# DELETE returns 5xx mid-cascade and the cascade finishes anyway,
|
||||
# and the case where DELETE legitimately exceeds 120s and we want
|
||||
# eventual-consistency confirmation.
|
||||
curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
|
||||
if curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1 \
|
||||
&& ok "Teardown request accepted" \
|
||||
|| log "Teardown returned non-2xx (may already be gone)"
|
||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1; then
|
||||
ok "Teardown request accepted"
|
||||
else
|
||||
log "Teardown returned non-2xx (may already be gone)"
|
||||
fi
|
||||
|
||||
local leak_count=1
|
||||
local elapsed=0
|
||||
@@ -144,7 +161,15 @@ cleanup_org() {
|
||||
echo "⚠️ LEAK: org $SLUG still present post-teardown after ${elapsed}s (count=$leak_count)" >&2
|
||||
exit 4
|
||||
fi
|
||||
ok "Teardown clean — no orphan resources for $SLUG (${elapsed}s)"
|
||||
local aws_leak_rc=0
|
||||
e2e_verify_no_ec2_leaks_for_slug "$SLUG" || aws_leak_rc=$?
|
||||
if [ "$aws_leak_rc" != "0" ]; then
|
||||
case "$aws_leak_rc" in
|
||||
2) exit 2 ;;
|
||||
*) exit 4 ;;
|
||||
esac
|
||||
fi
|
||||
ok "Teardown clean — no orphan org or EC2 resources for $SLUG (${elapsed}s)"
|
||||
|
||||
# Normalize unexpected upstream exit codes to 1 (generic failure). The
|
||||
# script's documented contract (header "Exit codes" section) only emits
|
||||
@@ -331,6 +356,75 @@ tenant_call() {
|
||||
"$@"
|
||||
}
|
||||
|
||||
sanitize_http_body() {
|
||||
python3 -c '
|
||||
import re, sys
|
||||
s = sys.stdin.read()
|
||||
s = re.sub(r"(?i)(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+", r"\1[redacted]", s)
|
||||
s = re.sub(r"(?i)(\"(?:auth_token|access_token|refresh_token|token|api_key|secret|password)\"\s*:\s*\")[^\"]+\"", r"\1[redacted]\"", s)
|
||||
s = re.sub(r"(?i)((?:auth_token|access_token|refresh_token|api_key|secret|password)=)[^&\s]+", r"\1[redacted]", s)
|
||||
print(s[:4000])
|
||||
'
|
||||
}
|
||||
|
||||
wait_workspaces_online_routable() {
|
||||
local label="$1"; shift
|
||||
local deadline=$(( $(date +%s) + WORKSPACE_ONLINE_TIMEOUT_SECS ))
|
||||
local wid ws_last_status ws_last_url ws_url_missing_logged ws_failed_logged
|
||||
local ws_json ws_status ws_url ws_last_err
|
||||
|
||||
log "$label"
|
||||
for wid in "$@"; do
|
||||
ws_last_status=""
|
||||
ws_last_url=""
|
||||
ws_url_missing_logged=0
|
||||
ws_failed_logged=0
|
||||
while true; do
|
||||
if [ "$(date +%s)" -gt "$deadline" ]; then
|
||||
ws_last_err=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
|
||||
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid never reached online with a routable URL within ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min) (last status=$ws_last_status, url=$ws_last_url, err=$ws_last_err)"
|
||||
fi
|
||||
ws_json=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
|
||||
ws_status=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status') or '')" 2>/dev/null)
|
||||
ws_url=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url') or '')" 2>/dev/null)
|
||||
if [ "$ws_status" != "$ws_last_status" ]; then
|
||||
log " $wid → $ws_status"
|
||||
ws_last_status="$ws_status"
|
||||
fi
|
||||
if [ -n "$ws_url" ] && [ "$ws_url" != "$ws_last_url" ]; then
|
||||
log " $wid url ready: $ws_url"
|
||||
ws_last_url="$ws_url"
|
||||
fi
|
||||
case "$ws_status" in
|
||||
online)
|
||||
if [ -n "$ws_url" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$ws_url_missing_logged" = "0" ]; then
|
||||
log " $wid online but URL is not assigned yet — waiting for workspace routing readiness"
|
||||
ws_url_missing_logged=1
|
||||
fi
|
||||
sleep 10
|
||||
;;
|
||||
failed)
|
||||
# Not a hard fail — bootstrap-watcher frequently marks failed at
|
||||
# 5 min on hermes, then heartbeat recovers to online around 10-13
|
||||
# min when install.sh finishes. Log once per workspace so the CI
|
||||
# output isn't spammy.
|
||||
if [ "$ws_failed_logged" = "0" ]; then
|
||||
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
|
||||
ws_failed_logged=1
|
||||
fi
|
||||
sleep 10
|
||||
;;
|
||||
*) sleep 10 ;;
|
||||
esac
|
||||
done
|
||||
ok " $wid online and routable"
|
||||
done
|
||||
}
|
||||
|
||||
# ─── 5. Provision parent workspace ─────────────────────────────────────
|
||||
# Inject the LLM provider key so the runtime can authenticate at boot.
|
||||
# Branch by which secret is set so the script supports multiple paths
|
||||
@@ -383,9 +477,9 @@ elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
# is still independent of MOLECULE_STAGING_OPENAI_API_KEY, so an OpenAI
|
||||
# quota collapse doesn't wedge this path. Pinned to the claude-code
|
||||
# runtime: hermes/langgraph use OpenAI-shaped envs and won't honour
|
||||
# ANTHROPIC_API_KEY without further wiring (out of scope for this
|
||||
# branch; if you need a hermes/Anthropic path, dispatch with
|
||||
# E2E_RUNTIME=hermes + E2E_OPENAI_API_KEY pointing at a working key).
|
||||
# ANTHROPIC_API_KEY without further wiring. pick_model_slug maps this
|
||||
# branch to claude-sonnet-4-6 so the claude-code provider registry
|
||||
# selects anthropic-api instead of the OAuth-only sonnet alias.
|
||||
SECRETS_JSON=$(python3 -c "
|
||||
import json, os
|
||||
k = os.environ['E2E_ANTHROPIC_API_KEY']
|
||||
@@ -410,6 +504,7 @@ print(json.dumps({
|
||||
fi
|
||||
|
||||
MODEL_SLUG=$(pick_model_slug "$RUNTIME")
|
||||
log " MODEL_SLUG=$MODEL_SLUG"
|
||||
|
||||
log "5/11 Provisioning parent workspace (runtime=$RUNTIME)..."
|
||||
PARENT_RESP=$(tenant_call POST /workspaces \
|
||||
@@ -437,48 +532,16 @@ fi
|
||||
# deadline fires at 5 min and sets status=failed prematurely; heartbeat
|
||||
# then transitions failed → online after install.sh finishes. So:
|
||||
#
|
||||
# - 20 min deadline (hermes worst-case + slack)
|
||||
# - ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min)
|
||||
# deadline (hermes worst-case + slack). Configurable via
|
||||
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS (#1646).
|
||||
# - 'failed' is a TRANSIENT state we must tolerate — log and keep
|
||||
# polling, only hard-fail at the deadline. Pre-bootstrap-watcher-fix
|
||||
# (controlplane#245) this was a flake generator: workspace went
|
||||
# failed→online inside our window but we bailed at the failed read.
|
||||
log "7/11 Waiting for workspace(s) to reach status=online (up to 30 min — hermes cold boot)..."
|
||||
WS_DEADLINE=$(( $(date +%s) + 1800 ))
|
||||
WS_TO_CHECK="$PARENT_ID"
|
||||
[ -n "$CHILD_ID" ] && WS_TO_CHECK="$WS_TO_CHECK $CHILD_ID"
|
||||
for wid in $WS_TO_CHECK; do
|
||||
WS_LAST_STATUS=""
|
||||
WS_FAILED_LOGGED=0
|
||||
while true; do
|
||||
if [ "$(date +%s)" -gt "$WS_DEADLINE" ]; then
|
||||
WS_LAST_ERR=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
|
||||
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid never reached online within 20 min (last status=$WS_LAST_STATUS, err=$WS_LAST_ERR)"
|
||||
fi
|
||||
WS_JSON=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
|
||||
WS_STATUS=$(echo "$WS_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status',''))" 2>/dev/null)
|
||||
if [ "$WS_STATUS" != "$WS_LAST_STATUS" ]; then
|
||||
log " $wid → $WS_STATUS"
|
||||
WS_LAST_STATUS="$WS_STATUS"
|
||||
fi
|
||||
case "$WS_STATUS" in
|
||||
online) break ;;
|
||||
failed)
|
||||
# Not a hard fail — bootstrap-watcher frequently marks failed at
|
||||
# 5 min on hermes, then heartbeat recovers to online around 10-13
|
||||
# min when install.sh finishes. Log once per workspace so the CI
|
||||
# output isn't spammy.
|
||||
if [ "$WS_FAILED_LOGGED" = "0" ]; then
|
||||
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
|
||||
WS_FAILED_LOGGED=1
|
||||
fi
|
||||
sleep 10
|
||||
;;
|
||||
*) sleep 10 ;;
|
||||
esac
|
||||
done
|
||||
ok " $wid online"
|
||||
done
|
||||
WS_TO_CHECK=("$PARENT_ID")
|
||||
[ -n "$CHILD_ID" ] && WS_TO_CHECK+=("$CHILD_ID")
|
||||
wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to $((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min — hermes cold boot)..." "${WS_TO_CHECK[@]}"
|
||||
|
||||
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
|
||||
# This step exists because the canvas-terminal failure of 2026-05-03
|
||||
@@ -490,7 +553,7 @@ done
|
||||
# - tenantIngressRules / workspaceIngressRules (CP)
|
||||
# - eicSSHIngressRule helper (CP)
|
||||
# - AuthorizeIngress source-group support (CP awsapi)
|
||||
# - EIC_ENDPOINT_SG_ID Railway env
|
||||
# - MOLECULE_EIC_ENDPOINT_SG_ID Railway env
|
||||
# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain
|
||||
# surfaces within ~20 min of merge instead of waiting for a user report.
|
||||
#
|
||||
@@ -504,7 +567,7 @@ done
|
||||
# probes docker.Ping + container exec; we still expect ok=true there
|
||||
# since local-docker is the alternative production path.
|
||||
log "7b/11 Canvas-terminal EIC diagnose probe..."
|
||||
for wid in $WS_TO_CHECK; do
|
||||
for wid in "${WS_TO_CHECK[@]}"; do
|
||||
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
|
||||
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
|
||||
if [ "$DIAG_OK" = "true" ]; then
|
||||
@@ -512,7 +575,7 @@ for wid in $WS_TO_CHECK; do
|
||||
else
|
||||
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; step=s[0] if s else {}; print(' — '.join(x for x in [step.get('error',''), step.get('detail','')] if x))" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
|
||||
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from the configured EIC endpoint SG, MOLECULE_EIC_ENDPOINT_SG_ID is set in Railway, and EIC endpoint health"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -540,7 +603,7 @@ CONFIG_PAYLOAD="${CONFIG_MARKER}
|
||||
name: synth-canary
|
||||
runtime: ${RUNTIME}
|
||||
"
|
||||
for wid in $WS_TO_CHECK; do
|
||||
for wid in "${WS_TO_CHECK[@]}"; do
|
||||
PUT_BODY=$(python3 -c "import json,sys; print(json.dumps({'content': sys.stdin.read()}))" <<< "$CONFIG_PAYLOAD")
|
||||
# Capture body to a tempfile so curl's -w '%{http_code}' is the only
|
||||
# thing on stdout. The first version used `-w '\n%{http_code}\n'` and
|
||||
@@ -573,6 +636,12 @@ for wid in $WS_TO_CHECK; do
|
||||
ok " $wid config.yaml PUT OK (HTTP $PUT_CODE)"
|
||||
done
|
||||
|
||||
# Saving config.yaml follows the same path as Canvas Config Save & Restart.
|
||||
# The controlplane can briefly put the workspace back into provisioning and
|
||||
# clear its route while the runtime restarts, so A2A must wait on the same
|
||||
# externally routable readiness boundary again.
|
||||
wait_workspaces_online_routable "7d/11 Waiting for workspace(s) to recover routing after config.yaml PUT..." "${WS_TO_CHECK[@]}"
|
||||
|
||||
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
|
||||
log "8/11 Sending A2A message to parent — expecting agent response..."
|
||||
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
|
||||
@@ -612,10 +681,44 @@ print(json.dumps({
|
||||
# 90s gives ~3x headroom over observed cold-call P95 (~25-30s).
|
||||
# Subsequent A2A turns hit the same workspace and are sub-second, so
|
||||
# this only widens the window for step 8/11 of the canary's first turn.
|
||||
A2A_RESP=$(tenant_call POST "/workspaces/$PARENT_ID/a2a" \
|
||||
--max-time 90 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$A2A_PAYLOAD")
|
||||
A2A_TMP=$(mktemp -t synth_a2a.XXXXXX)
|
||||
for A2A_ATTEMPT in $(seq 1 12); do
|
||||
: >"$A2A_TMP"
|
||||
set +e
|
||||
A2A_CODE=$(tenant_call POST "/workspaces/$PARENT_ID/a2a" \
|
||||
--max-time 90 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$A2A_PAYLOAD" \
|
||||
-o "$A2A_TMP" \
|
||||
-w '%{http_code}' \
|
||||
2>/dev/null)
|
||||
A2A_RC=$?
|
||||
set -e
|
||||
A2A_CODE=${A2A_CODE:-000}
|
||||
A2A_RESP=$(cat "$A2A_TMP" 2>/dev/null || echo "")
|
||||
if [ "$A2A_RC" = "0" ] && [ "$A2A_CODE" -ge 200 ] && [ "$A2A_CODE" -lt 300 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
A2A_SAFE_BODY=$(printf '%s' "$A2A_RESP" | sanitize_http_body)
|
||||
if echo "$A2A_CODE" | grep -Eq '^(502|503|504)$' && echo "$A2A_SAFE_BODY" | grep -Eqi 'Service Unavailable|Bad Gateway|Gateway Timeout|error code: 502|error code: 504|workspace agent unreachable|connection refused|no healthy upstream|workspace agent busy|native_session'; then
|
||||
log " A2A cold-start probe attempt $A2A_ATTEMPT/12 returned $A2A_CODE: $A2A_SAFE_BODY"
|
||||
if [ "$A2A_ATTEMPT" -lt 12 ]; then
|
||||
A2A_SLEEP=10
|
||||
if echo "$A2A_SAFE_BODY" | grep -Eqi 'workspace agent busy|native_session'; then
|
||||
A2A_SLEEP=30
|
||||
fi
|
||||
sleep "$A2A_SLEEP"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
break
|
||||
done
|
||||
rm -f "$A2A_TMP"
|
||||
if [ "$A2A_RC" != "0" ] || [ "$A2A_CODE" -lt 200 ] || [ "$A2A_CODE" -ge 300 ]; then
|
||||
A2A_SAFE_BODY=$(printf '%s' "$A2A_RESP" | sanitize_http_body)
|
||||
fail "A2A POST /workspaces/$PARENT_ID/a2a failed after $A2A_ATTEMPT attempt(s) (curl_rc=$A2A_RC, http=$A2A_CODE): $A2A_SAFE_BODY"
|
||||
fi
|
||||
AGENT_TEXT=$(echo "$A2A_RESP" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
@@ -812,20 +915,50 @@ print(json.dumps({
|
||||
}
|
||||
}))
|
||||
")
|
||||
set +e
|
||||
# Raw curl (not tenant_call) because this call carries an extra
|
||||
# X-Source-Workspace-Id header. Must still send X-Molecule-Org-Id
|
||||
# or TenantGuard 404s — previously missing, caused section 10 to
|
||||
# fail rc=22 despite everything upstream being correct (2026-04-21).
|
||||
DELEG_RESP=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$CHILD_ID/a2a" \
|
||||
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "X-Source-Workspace-Id: $PARENT_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$DELEG_PAYLOAD")
|
||||
DELEG_RC=$?
|
||||
set -e
|
||||
[ $DELEG_RC -ne 0 ] && fail "Delegation A2A POST failed (rc=$DELEG_RC)"
|
||||
DELEG_TMP=$(mktemp -t deleg_a2a.XXXXXX)
|
||||
for DELEG_ATTEMPT in $(seq 1 12); do
|
||||
: >"$DELEG_TMP"
|
||||
set +e
|
||||
# Raw curl (not tenant_call) because this call carries an extra
|
||||
# X-Source-Workspace-Id header. Must still send X-Molecule-Org-Id
|
||||
# or TenantGuard 404s — previously missing, caused section 10 to
|
||||
# fail rc=22 despite everything upstream being correct (2026-04-21).
|
||||
DELEG_CODE=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$CHILD_ID/a2a" \
|
||||
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "X-Source-Workspace-Id: $PARENT_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$DELEG_PAYLOAD" \
|
||||
-o "$DELEG_TMP" \
|
||||
-w '%{http_code}' \
|
||||
2>/dev/null)
|
||||
DELEG_RC=$?
|
||||
set -e
|
||||
DELEG_CODE=${DELEG_CODE:-000}
|
||||
DELEG_RESP=$(cat "$DELEG_TMP" 2>/dev/null || echo "")
|
||||
if [ "$DELEG_RC" = "0" ] && [ "$DELEG_CODE" -ge 200 ] && [ "$DELEG_CODE" -lt 300 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
DELEG_SAFE_BODY=$(printf '%s' "$DELEG_RESP" | sanitize_http_body)
|
||||
if echo "$DELEG_CODE" | grep -Eq '^(502|503|504)$' && echo "$DELEG_SAFE_BODY" | grep -Eqi 'Service Unavailable|Bad Gateway|Gateway Timeout|error code: 502|error code: 504|workspace agent unreachable|connection refused|no healthy upstream|workspace agent busy|native_session'; then
|
||||
log " Delegation A2A cold-start attempt $DELEG_ATTEMPT/12 returned $DELEG_CODE: $DELEG_SAFE_BODY"
|
||||
if [ "$DELEG_ATTEMPT" -lt 12 ]; then
|
||||
DELEG_SLEEP=10
|
||||
if echo "$DELEG_SAFE_BODY" | grep -Eqi 'workspace agent busy|native_session'; then
|
||||
DELEG_SLEEP=30
|
||||
fi
|
||||
sleep "$DELEG_SLEEP"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
break
|
||||
done
|
||||
rm -f "$DELEG_TMP"
|
||||
if [ "$DELEG_RC" != "0" ] || [ "$DELEG_CODE" -lt 200 ] || [ "$DELEG_CODE" -ge 300 ]; then
|
||||
DELEG_SAFE_BODY=$(printf '%s' "$DELEG_RESP" | sanitize_http_body)
|
||||
fail "Delegation A2A POST failed after $DELEG_ATTEMPT attempt(s) (curl_rc=$DELEG_RC, http=$DELEG_CODE): $DELEG_SAFE_BODY"
|
||||
fi
|
||||
DELEG_TEXT=$(echo "$DELEG_RESP" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
|
||||
@@ -27,9 +27,9 @@ create_workspace() {
|
||||
local tenant="$1" name="$2" tier="$3" parent="${4:-}"
|
||||
local body
|
||||
if [ -n "$parent" ]; then
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"parent_id\":\"$parent\",\"runtime\":\"langgraph\"}"
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"parent_id\":\"$parent\",\"runtime\":\"claude-code\",\"model\":\"sonnet\"}"
|
||||
else
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"runtime\":\"langgraph\"}"
|
||||
body="{\"name\":\"$name\",\"tier\":$tier,\"runtime\":\"claude-code\",\"model\":\"sonnet\"}"
|
||||
fi
|
||||
local id
|
||||
if [ "$tenant" = "alpha" ]; then
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_staging_e2e_workflows_use_stable_minimax_default() -> None:
|
||||
"""Keep cron/push E2E on the same MiniMax model as the smoke-tested script."""
|
||||
workflow_paths = [
|
||||
".gitea/workflows/e2e-staging-saas.yml",
|
||||
".gitea/workflows/staging-smoke.yml",
|
||||
".gitea/workflows/continuous-synth-e2e.yml",
|
||||
]
|
||||
|
||||
for rel in workflow_paths:
|
||||
text = (ROOT / rel).read_text()
|
||||
assert "MiniMax-M2.7-highspeed" not in text
|
||||
assert "MiniMax-M2" in text
|
||||
@@ -705,7 +705,7 @@ def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
|
||||
}
|
||||
|
||||
|
||||
def test_ci_platform_go_pr_steps_are_path_scoped():
|
||||
def test_ci_platform_go_steps_are_path_scoped_on_all_events():
|
||||
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
|
||||
platform = doc["jobs"]["platform-build"]
|
||||
assert platform.get("needs") == "changes"
|
||||
@@ -720,11 +720,11 @@ def test_ci_platform_go_pr_steps_are_path_scoped():
|
||||
assert expensive_steps
|
||||
for step in expensive_steps:
|
||||
expr = step.get("if", "")
|
||||
assert "github.event_name != 'pull_request'" in expr
|
||||
assert "needs.changes.outputs.platform == 'true'" in expr
|
||||
assert "github.event_name != 'pull_request'" not in expr
|
||||
|
||||
|
||||
def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
|
||||
def test_ci_canvas_nextjs_steps_are_path_scoped_on_all_events():
|
||||
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
|
||||
canvas = doc["jobs"]["canvas-build"]
|
||||
assert canvas.get("needs") == "changes"
|
||||
@@ -739,11 +739,11 @@ def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
|
||||
assert expensive_steps
|
||||
for step in expensive_steps:
|
||||
expr = step.get("if", "")
|
||||
assert "github.event_name != 'pull_request'" in expr
|
||||
assert "needs.changes.outputs.canvas == 'true'" in expr
|
||||
assert "github.event_name != 'pull_request'" not in expr
|
||||
|
||||
|
||||
def test_ci_shellcheck_pr_steps_are_path_scoped():
|
||||
def test_ci_shellcheck_steps_are_path_scoped_on_all_events():
|
||||
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
|
||||
shellcheck = doc["jobs"]["shellcheck"]
|
||||
assert shellcheck.get("needs") == "changes"
|
||||
@@ -756,5 +756,5 @@ def test_ci_shellcheck_pr_steps_are_path_scoped():
|
||||
assert expensive_steps
|
||||
for step in expensive_steps:
|
||||
expr = step.get("if", "")
|
||||
assert "github.event_name != 'pull_request'" in expr
|
||||
assert "needs.changes.outputs.scripts == 'true'" in expr
|
||||
assert "github.event_name != 'pull_request'" not in expr
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Reverse of 000_schema_bootstrap.up.sql.
|
||||
--
|
||||
-- Drops the dedicated schema and every plugin object inside it. Operator
|
||||
-- runs this only when intentionally tearing down the v2 plugin store on
|
||||
-- a shared tenant Postgres. Down migrations are NOT auto-applied by the
|
||||
-- plugin (cmd/memory-plugin-postgres/main.go:runMigrations comment) —
|
||||
-- this file is for manual operator-driven cleanup.
|
||||
|
||||
DROP SCHEMA IF EXISTS memory_plugin CASCADE;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Create the dedicated schema this plugin owns when it shares a Postgres
|
||||
-- with the tenant platform (the default deployment shape — see
|
||||
-- entrypoint-tenant.sh which appends `search_path=memory_plugin,public`
|
||||
-- to DATABASE_URL when the operator hasn't set MEMORY_PLUGIN_DATABASE_URL
|
||||
-- explicitly).
|
||||
--
|
||||
-- The schema name `memory_plugin` matches the search_path injected by
|
||||
-- the entrypoint; sql.Open's URL controls *where* subsequent CREATE
|
||||
-- TABLE lands (search_path) but does NOT auto-create the target schema,
|
||||
-- so the plugin has to do it itself before 001_memory_v2 runs.
|
||||
--
|
||||
-- About the `,public` fallback in search_path: pgvector ships as an
|
||||
-- extension that lives in one schema. On fresh tenants 001_memory_v2's
|
||||
-- `CREATE EXTENSION IF NOT EXISTS vector` installs it into the first
|
||||
-- writable schema in search_path (memory_plugin — SSOT preserved). On
|
||||
-- tenants where pgvector was already installed into `public` by a
|
||||
-- prior boot, the IF NOT EXISTS is a no-op and the extension stays in
|
||||
-- public; the `vector(1536)` type reference in 001 then resolves via
|
||||
-- the public fallback. Without that fallback, 001 would die with
|
||||
-- "type vector does not exist" on every pre-existing tenant (#1742
|
||||
-- review finding).
|
||||
--
|
||||
-- Operators who point the plugin at a dedicated database (no shared
|
||||
-- tenant tables) can leave this no-op: CREATE SCHEMA IF NOT EXISTS is
|
||||
-- idempotent and a fresh DB happily owns a `memory_plugin` schema.
|
||||
--
|
||||
-- Migration files are sorted alphabetically (cmd/memory-plugin-postgres/
|
||||
-- main.go:runMigrationsFromEmbed → sort.Strings), so 000_ runs before
|
||||
-- 001_memory_v2 which assumes the schema already exists in search_path.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS memory_plugin;
|
||||
@@ -34,13 +34,49 @@ func TestMigrationsEmbedded_ContainsCreateTable(t *testing.T) {
|
||||
t.Errorf("read embedded %q: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(string(data), "CREATE TABLE") {
|
||||
t.Errorf("embedded %q has no CREATE TABLE — wrong file embedded?", e.Name())
|
||||
// Each file must contain at least one DDL statement we expect to see
|
||||
// — guards against truncated / empty files that would silently embed.
|
||||
if !containsAnyDDL(string(data)) {
|
||||
t.Errorf("embedded %q contains no recognized DDL (CREATE TABLE/SCHEMA/EXTENSION/INDEX) — wrong or truncated file?", e.Name())
|
||||
}
|
||||
}
|
||||
if !seenUp {
|
||||
t.Fatal("no *.up.sql in embedded migrations — runtime would have no schema to apply")
|
||||
}
|
||||
|
||||
// Per-file invariants (issue #1742 review finding). The previous global
|
||||
// "at least one file has CREATE TABLE somewhere" check let a future
|
||||
// rewrite of 001_memory_v2.up.sql silently regress to schema-only as
|
||||
// long as any other file declared a table. Pin the load-bearing DDL
|
||||
// per filename so a wrong-or-truncated 001 fails this test loudly.
|
||||
assertFileContains(t, "000_schema_bootstrap.up.sql", "CREATE SCHEMA")
|
||||
assertFileContains(t, "001_memory_v2.up.sql", "CREATE TABLE")
|
||||
assertFileContains(t, "001_memory_v2.up.sql", "memory_records")
|
||||
assertFileContains(t, "001_memory_v2.up.sql", "memory_namespaces")
|
||||
}
|
||||
|
||||
// assertFileContains fails the test if the embedded migration `name`
|
||||
// either can't be read or doesn't contain `needle`. Pulled out so each
|
||||
// per-file pin reads as one line.
|
||||
func assertFileContains(t *testing.T, name, needle string) {
|
||||
t.Helper()
|
||||
data, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
t.Errorf("required migration %q not embedded: %v", name, err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(string(data), needle) {
|
||||
t.Errorf("migration %q must contain %q — wrong or truncated file?", name, needle)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAnyDDL(sql string) bool {
|
||||
for _, kw := range []string{"CREATE TABLE", "CREATE SCHEMA", "CREATE EXTENSION", "CREATE INDEX", "CREATE OR REPLACE", "ALTER TABLE"} {
|
||||
if strings.Contains(sql, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestRunMigrationsFromEmbed_OrderingIsAlphabetic pins that we apply
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
t.Errorf("org id header: got %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app"}`)
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"new-secret","MOLECULE_CP_URL":"https://api.moleculesai.app","DISPLAY_SESSION_SIGNING_SECRET":"display-secret"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
@@ -45,6 +45,9 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
if got := os.Getenv("MOLECULE_CP_SHARED_SECRET"); got != "new-secret" {
|
||||
t.Errorf("SHARED_SECRET: want new-secret, got %q", got)
|
||||
}
|
||||
if got := os.Getenv("DISPLAY_SESSION_SIGNING_SECRET"); got != "display-secret" {
|
||||
t.Errorf("DISPLAY_SESSION_SIGNING_SECRET: want display-secret, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_CPUnreachableDoesNotFailBoot: network errors must
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
// Package main runs the per-tenant workspace-server.
|
||||
//
|
||||
// @title Molecule AI Workspace Server API
|
||||
// @version 1.0
|
||||
// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.
|
||||
// @host api.moleculesai.app
|
||||
// @BasePath /
|
||||
// @schemes https
|
||||
//
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.
|
||||
//
|
||||
// @securityDefinitions.apikey OrgSlugAuth
|
||||
// @in header
|
||||
// @name X-Molecule-Org-Slug
|
||||
// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.
|
||||
//
|
||||
// @securityDefinitions.apikey OrgIdAuth
|
||||
// @in header
|
||||
// @name X-Molecule-Org-Id
|
||||
// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -187,6 +210,12 @@ func main() {
|
||||
memBundle := memwiring.Build(db.DB)
|
||||
if memBundle != nil {
|
||||
wh.WithNamespaceCleanup(memBundle.NamespaceCleanupFn())
|
||||
// Issue #1755: route workspace-create `initial_memories` through
|
||||
// the v2 plugin instead of the legacy `agent_memories` table.
|
||||
// Same plugin client the MCP tools use, same namespace
|
||||
// (`workspace:<id>`); writes are visible to subsequent
|
||||
// `recall_memory` calls on the same workspace.
|
||||
wh.WithSeedMemoryPlugin(memBundle.Plugin)
|
||||
}
|
||||
|
||||
// External-plugin env mutators — each plugin contributes 0+ mutators
|
||||
@@ -370,8 +399,9 @@ func main() {
|
||||
// See molecule-core#7.
|
||||
bindHost := resolveBindHost()
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"schemes": [
|
||||
"https"
|
||||
],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.",
|
||||
"title": "Molecule AI Workspace Server API",
|
||||
"contact": {},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "api.moleculesai.app",
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/workspaces/{id}/schedules": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "List schedules for a workspace",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/handlers.ScheduleResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Create a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Schedule fields",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateScheduleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateScheduleResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules/{scheduleId}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Delete a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.StatusResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Update a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Partial schedule fields (only provided keys are updated)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UpdateScheduleRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ScheduleResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules/{scheduleId}/history": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Get past runs of a schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/handlers.HistoryEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules/{scheduleId}/run": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"schedules"
|
||||
],
|
||||
"summary": "Fire a schedule manually",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Schedule ID",
|
||||
"name": "scheduleId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.RunNowResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"handlers.CreateScheduleRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cron_expr",
|
||||
"prompt"
|
||||
],
|
||||
"properties": {
|
||||
"cron_expr": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.CreateScheduleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"next_run_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.HistoryEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"error_detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"request": {
|
||||
"type": "object"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.RunNowResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ScheduleResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"cron_expr": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_error": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_run_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"next_run_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"run_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.",
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.StatusResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UpdateScheduleRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cron_expr": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
},
|
||||
"OrgIdAuth": {
|
||||
"description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.",
|
||||
"type": "apiKey",
|
||||
"name": "X-Molecule-Org-Id",
|
||||
"in": "header"
|
||||
},
|
||||
"OrgSlugAuth": {
|
||||
"description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.",
|
||||
"type": "apiKey",
|
||||
"name": "X-Molecule-Org-Slug",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
handlers.CreateScheduleRequest:
|
||||
properties:
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
prompt:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
required:
|
||||
- cron_expr
|
||||
- prompt
|
||||
type: object
|
||||
handlers.CreateScheduleResponse:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
next_run_at:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
handlers.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
handlers.HistoryEntry:
|
||||
properties:
|
||||
duration_ms:
|
||||
type: integer
|
||||
error_detail:
|
||||
type: string
|
||||
request:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
timestamp:
|
||||
type: string
|
||||
type: object
|
||||
handlers.RunNowResponse:
|
||||
properties:
|
||||
prompt:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
type: object
|
||||
handlers.ScheduleResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
last_error:
|
||||
type: string
|
||||
last_run_at:
|
||||
type: string
|
||||
last_status:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
next_run_at:
|
||||
type: string
|
||||
prompt:
|
||||
type: string
|
||||
run_count:
|
||||
type: integer
|
||||
source:
|
||||
description: '''template'' (seeded by org/import) | ''runtime'' (created via
|
||||
Canvas/API). Issue #24.'
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
type: object
|
||||
handlers.StatusResponse:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
handlers.UpdateScheduleRequest:
|
||||
properties:
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
prompt:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
type: object
|
||||
host: api.moleculesai.app
|
||||
info:
|
||||
contact: {}
|
||||
description: 'The per-tenant workspace-server HTTP API. Single source of truth for
|
||||
workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas,
|
||||
molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by
|
||||
clients generated from this spec — see RFC #1706.'
|
||||
title: Molecule AI Workspace Server API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/workspaces/{id}/schedules:
|
||||
get:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/handlers.ScheduleResponse'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: List schedules for a workspace
|
||||
tags:
|
||||
- schedules
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule fields
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateScheduleRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateScheduleResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Create a schedule
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/schedules/{scheduleId}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.StatusResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Delete a schedule
|
||||
tags:
|
||||
- schedules
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
- description: Partial schedule fields (only provided keys are updated)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.UpdateScheduleRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ScheduleResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Update a schedule
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/schedules/{scheduleId}/history:
|
||||
get:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/handlers.HistoryEntry'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Get past runs of a schedule
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/schedules/{scheduleId}/run:
|
||||
post:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Schedule ID
|
||||
in: path
|
||||
name: scheduleId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.RunNowResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
summary: Fire a schedule manually
|
||||
tags:
|
||||
- schedules
|
||||
schemes:
|
||||
- https
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Bearer token issued by Gitea (org-admin or persona PAT) or by the
|
||||
platform's signup/SSO flow.
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
OrgIdAuth:
|
||||
description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug.
|
||||
At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
|
||||
in: header
|
||||
name: X-Molecule-Org-Id
|
||||
type: apiKey
|
||||
OrgSlugAuth:
|
||||
description: Tenant routing header — required on every /workspaces/{id}/* request
|
||||
so the platform edge can route to the correct per-tenant workspace-server. Either
|
||||
X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id
|
||||
(UUID) must be sent; slug is preferred for client code.
|
||||
in: header
|
||||
name: X-Molecule-Org-Slug
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
@@ -23,22 +23,57 @@ CANVAS_PID=$!
|
||||
# Memory v2 sidecar (built-in postgres plugin). See Dockerfile entrypoint
|
||||
# comment for rationale.
|
||||
#
|
||||
# Spawn-gating: only start the sidecar when the operator has indicated
|
||||
# they want it (MEMORY_V2_CUTOVER=true OR MEMORY_PLUGIN_URL set).
|
||||
# Without that signal, the sidecar adds zero value and risks aborting
|
||||
# tenant boot via the 30s health gate when the tenant Postgres lacks
|
||||
# Spawn-gating: start the sidecar when MEMORY_PLUGIN_URL is set.
|
||||
# Without it, the sidecar adds zero value and risks aborting tenant
|
||||
# boot via the 30s health gate when the tenant Postgres lacks
|
||||
# pgvector. Caught on staging redeploy 2026-05-05:
|
||||
# pq: extension "vector" is not available
|
||||
#
|
||||
# Defaults (when sidecar IS spawned): MEMORY_PLUGIN_DATABASE_URL
|
||||
# falls back to the tenant's DATABASE_URL.
|
||||
#
|
||||
# MEMORY_V2_CUTOVER is deprecated as of #1747 — the workspace-server
|
||||
# binary no longer reads it (v2 is unconditional now; the legacy SQL
|
||||
# fallback in mcp_tools.go is gone). The entrypoint still accepts it
|
||||
# as a synonym for "operator wants the sidecar" so old CP user-data
|
||||
# templates keep working through the rollout. When CP user-data drops
|
||||
# the var, this branch can go.
|
||||
MEMORY_PLUGIN_PID=""
|
||||
memory_plugin_wanted=""
|
||||
if [ "$MEMORY_V2_CUTOVER" = "true" ] || [ -n "$MEMORY_PLUGIN_URL" ]; then
|
||||
if [ -n "$MEMORY_PLUGIN_URL" ]; then
|
||||
memory_plugin_wanted=1
|
||||
elif [ "$MEMORY_V2_CUTOVER" = "true" ]; then
|
||||
memory_plugin_wanted=1
|
||||
echo "memory-plugin: ⚠️ MEMORY_V2_CUTOVER is deprecated (#1747) — set MEMORY_PLUGIN_URL instead. Spawning sidecar on the implied default this boot." >&2
|
||||
fi
|
||||
if [ -z "$MEMORY_PLUGIN_DISABLE" ] && [ -n "$memory_plugin_wanted" ] && [ -n "$DATABASE_URL" ]; then
|
||||
: "${MEMORY_PLUGIN_DATABASE_URL:=$DATABASE_URL}"
|
||||
# Schema isolation (issue #1733): when defaulting from the tenant
|
||||
# DATABASE_URL we co-locate the plugin's tables under a dedicated
|
||||
# `memory_plugin` schema so they never collide with platform-tenant
|
||||
# tables in `public`. The plugin's 000_schema_bootstrap migration
|
||||
# creates the schema; search_path here directs every subsequent CREATE
|
||||
# TABLE / SELECT to land in it.
|
||||
#
|
||||
# The search_path includes `public` as a fallback so the `vector` type
|
||||
# resolves regardless of which schema pgvector was installed into.
|
||||
# Fresh tenants (no prior `CREATE EXTENSION vector`) install the
|
||||
# extension into `memory_plugin` (first writable schema in the path),
|
||||
# keeping the SSOT intent. Tenants where pgvector was already
|
||||
# installed into `public` by a prior boot or operator action keep the
|
||||
# extension where it is and resolve `vector(1536)` via the public
|
||||
# fallback — without this fallback those tenants would crash the
|
||||
# plugin boot with "type vector does not exist" once the migrations
|
||||
# try to create memory_records (#1742 review finding).
|
||||
#
|
||||
# Operators who explicitly set MEMORY_PLUGIN_DATABASE_URL (separate DB
|
||||
# entirely) keep full control — search_path is only injected when we
|
||||
# default from DATABASE_URL.
|
||||
if [ -z "$MEMORY_PLUGIN_DATABASE_URL" ]; then
|
||||
case "$DATABASE_URL" in
|
||||
*\?*) MEMORY_PLUGIN_DATABASE_URL="${DATABASE_URL}&search_path=memory_plugin,public" ;;
|
||||
*) MEMORY_PLUGIN_DATABASE_URL="${DATABASE_URL}?search_path=memory_plugin,public" ;;
|
||||
esac
|
||||
fi
|
||||
: "${MEMORY_PLUGIN_LISTEN_ADDR:=:9100}"
|
||||
export MEMORY_PLUGIN_DATABASE_URL MEMORY_PLUGIN_LISTEN_ADDR
|
||||
echo "memory-plugin: starting sidecar on $MEMORY_PLUGIN_LISTEN_ADDR" >&2
|
||||
|
||||
@@ -60,8 +60,8 @@ func Import(
|
||||
// Build config files in memory for the provisioner
|
||||
configFiles := buildBundleConfigFiles(b)
|
||||
|
||||
// Extract runtime from config.yaml in the bundle
|
||||
bundleRuntime := "langgraph"
|
||||
// Extract runtime from config.yaml in the bundle.
|
||||
bundleRuntime := "claude-code"
|
||||
if configYaml, ok := b.Prompts["config.yaml"]; ok {
|
||||
for _, line := range strings.Split(configYaml, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
@@ -116,8 +116,11 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
|
||||
// would propagate that token into logs and error responses (#659).
|
||||
return fmt.Errorf("discord: HTTP request failed")
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("discord: read response body: %w", readErr)
|
||||
}
|
||||
|
||||
// Discord returns 204 No Content on success.
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -119,7 +119,10 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("lark: read response body: %w", readErr)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
@@ -156,6 +156,9 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: pause-pollers rows.Err: %v", err)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(pausedIDs) == 0 {
|
||||
@@ -204,8 +207,16 @@ func (m *Manager) Reload(ctx context.Context) {
|
||||
log.Printf("Channels: reload scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
_ = json.Unmarshal(configJSON, &ch.Config)
|
||||
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
|
||||
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
|
||||
log.Printf("Channels: reload config unmarshal error for %s: %v", truncID(ch.ID), err)
|
||||
continue
|
||||
}
|
||||
if len(allowedJSON) > 0 {
|
||||
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
|
||||
log.Printf("Channels: reload allowed_users unmarshal error for %s: %v", truncID(ch.ID), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// #319: decrypt at the boundary between DB (ciphertext) and the
|
||||
// in-memory config adapters consume. A decrypt failure logs and
|
||||
// skips the channel — downstream getUpdates would fail anyway
|
||||
@@ -216,6 +227,9 @@ func (m *Manager) Reload(ctx context.Context) {
|
||||
}
|
||||
desired[ch.ID] = ch
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: reload rows.Err: %v", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -473,6 +487,9 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Channels: broadcast rows.Err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
|
||||
@@ -555,8 +572,14 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
|
||||
if err != nil {
|
||||
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
|
||||
}
|
||||
json.Unmarshal(configJSON, &ch.Config)
|
||||
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
|
||||
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
|
||||
return ch, fmt.Errorf("channel %s config unmarshal: %w", channelID, err)
|
||||
}
|
||||
if len(allowedJSON) > 0 {
|
||||
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
|
||||
return ch, fmt.Errorf("channel %s allowed_users unmarshal: %w", channelID, err)
|
||||
}
|
||||
}
|
||||
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
|
||||
// methods downstream read them as plaintext strings.
|
||||
if err := DecryptSensitiveFields(ch.Config); err != nil {
|
||||
|
||||
@@ -171,8 +171,11 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("slack: read response body: %w", readErr)
|
||||
}
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
@@ -208,9 +211,13 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
|
||||
}
|
||||
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
@@ -524,8 +531,11 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 65536))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("slack: read history response: %w", readErr)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
|
||||
@@ -111,12 +111,13 @@ const maxProxyResponseBody = 10 << 20
|
||||
// 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
|
||||
// 3. Transport.ResponseHeaderTimeout — 5min 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
|
||||
// turns and Codex scheduled tasks (big context + internal delegate_task
|
||||
// round-trips routinely exceed the old 60s/180s ceilings). Body streaming
|
||||
// after headers is governed by the
|
||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||
// responses still work fine.
|
||||
//
|
||||
@@ -131,7 +132,7 @@ var a2aClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 5*time.Minute),
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
|
||||
// fan-in is bounded by the platform's broadcaster fan-out, not by
|
||||
@@ -228,7 +229,7 @@ func (e *proxyA2AError) Error() string {
|
||||
// cron scheduler and other internal callers that need to send A2A messages
|
||||
// to workspaces programmatically (not from an HTTP handler).
|
||||
func (h *WorkspaceHandler) ProxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
|
||||
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity)
|
||||
status, resp, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, logActivity, false)
|
||||
if proxyErr != nil {
|
||||
return status, resp, proxyErr
|
||||
}
|
||||
@@ -307,13 +308,21 @@ func (h *WorkspaceHandler) ProxyA2A(c *gin.Context) {
|
||||
// The bind is strict: the token must match `callerID`, not
|
||||
// `workspaceID` (the target). A compromised token from workspace A
|
||||
// must never authenticate calls from A pretending to be B.
|
||||
if callerID != "" && callerID != workspaceID {
|
||||
if err := validateCallerToken(ctx, c, callerID); err != nil {
|
||||
//
|
||||
// Post-RFC#637: canvas users now send X-Workspace-ID (their identity
|
||||
// workspace). validateCallerToken detects canvas/admin auth on a
|
||||
// tokenless workspace and returns isCanvasUser=true so the proxy can
|
||||
// bypass CanCommunicate (human users sit outside the hierarchy).
|
||||
isCanvasUser := false
|
||||
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
|
||||
var err error
|
||||
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
|
||||
if err != nil {
|
||||
return // response already written with 401
|
||||
}
|
||||
}
|
||||
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true)
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, body, callerID, true, isCanvasUser)
|
||||
if proxyErr != nil {
|
||||
for k, v := range proxyErr.Headers {
|
||||
c.Header(k, v)
|
||||
@@ -352,11 +361,13 @@ func (h *WorkspaceHandler) checkWorkspaceBudget(ctx context.Context, workspaceID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, *proxyA2AError) {
|
||||
func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool, isCanvasUser bool) (int, []byte, *proxyA2AError) {
|
||||
// Access control: workspace-to-workspace requests must pass CanCommunicate check.
|
||||
// Canvas requests (callerID == "") and system callers (webhook:*, system:*, test:*)
|
||||
// are trusted. Self-calls (callerID == workspaceID) are always allowed.
|
||||
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
|
||||
// Post-RFC#637: canvas-user identity workspaces also bypass CanCommunicate
|
||||
// because human users sit outside the org hierarchy.
|
||||
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
|
||||
if !registry.CanCommunicate(callerID, workspaceID) {
|
||||
log.Printf("ProxyA2A: access denied %s → %s", callerID, workspaceID)
|
||||
return 0, nil, &proxyA2AError{
|
||||
@@ -694,7 +705,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
|
||||
if strings.HasPrefix(agentURL, "http://127.0.0.1:") && h.provisioner != nil && platformInDocker {
|
||||
var wsRuntime string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
|
||||
`SELECT COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsRuntime); err != nil {
|
||||
log.Printf("ProxyA2A: runtime lookup before Docker URL rewrite failed for %s: %v", workspaceID, err)
|
||||
|
||||
@@ -5,17 +5,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/orgtoken"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -28,8 +32,8 @@ type proxyDispatchBuildError struct{ err error }
|
||||
func (e *proxyDispatchBuildError) Error() string { return e.err.Error() }
|
||||
|
||||
// handleA2ADispatchError translates a forward-call failure into a proxyA2AError,
|
||||
// runs the reactive container-health check, and (when `logActivity` is true)
|
||||
// schedules a detached LogActivity goroutine for the failed attempt.
|
||||
// runs the reactive container-health check, and records the outcome. Busy
|
||||
// targets that are successfully queued are logged as queued, not failed.
|
||||
func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int, logActivity bool) (int, []byte, *proxyA2AError) {
|
||||
// Build-time failure (couldn't even create the http.Request) — return
|
||||
// a 500 without the reactive-health / busy-retry paths.
|
||||
@@ -45,10 +49,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
|
||||
containerDead := h.maybeMarkContainerDead(ctx, workspaceID)
|
||||
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
if containerDead {
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{"error": "workspace agent unreachable — container restart triggered", "restarting": true},
|
||||
@@ -71,35 +75,30 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// with 202 status here was the original cycle 53 bug — callers saw
|
||||
// proxyErr != nil and logged "delegation failed: proxy a2a error".
|
||||
if isUpstreamBusyError(err) {
|
||||
// Capability primitive #5 — see project memory
|
||||
// `project_runtime_native_pluggable.md`. When the target workspace's
|
||||
// adapter has declared provides_native_session=True, the SDK
|
||||
// owns its own queue/session state (claude-agent-sdk's streaming
|
||||
// session, hermes-agent's in-container event log, etc.). Adding
|
||||
// the platform's a2a_queue layer on top would double-buffer the
|
||||
// same in-flight state — and worse, the platform queue's drain
|
||||
// timing has no relationship to the SDK's actual readiness, so
|
||||
// the queued request might dispatch while the SDK is STILL busy.
|
||||
// #1684 / Reno Stars: native_session adapters previously took a
|
||||
// 503-no-enqueue path here, on the assumption that the SDK owned
|
||||
// an inbound queue and the platform a2a_queue would double-buffer.
|
||||
// In practice, the common native_session SDKs (claude-agent-sdk,
|
||||
// codex app-server, hermes-agent) do NOT have an inbound queue —
|
||||
// new turns can only be pushed via the same HTTP POST that just
|
||||
// returned busy. So cron fires (and any A2A retry) bounce 503
|
||||
// every tick until the SDK voluntarily yields. Reno Stars #1684
|
||||
// observed 12 consecutive `*/30` cron fires lost over 6h while a
|
||||
// single native_session held the slot.
|
||||
//
|
||||
// For native_session targets, return 503 + Retry-After directly.
|
||||
// The caller's adapter handles retry on its own schedule, and
|
||||
// the SDK's own queue absorbs the in-flight request when it does.
|
||||
// Observability is preserved: logA2AFailure already ran above;
|
||||
// activity_logs records the busy event; the broadcaster fires.
|
||||
if runtimeOverrides.HasCapability(workspaceID, "session") {
|
||||
log.Printf("ProxyA2A: target %s busy and declares native_session — skip enqueue, return 503", workspaceID)
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
Response: gin.H{
|
||||
"error": "workspace agent busy — adapter handles retry (native_session)",
|
||||
"busy": true,
|
||||
"retry_after": busyRetryAfterSeconds,
|
||||
"native_session": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The original concern — "drain timing has no relationship to SDK
|
||||
// readiness" — turns out to be unfounded: heartbeat→drain is
|
||||
// gated by `payload.ActiveTasks < maxConcurrent` in
|
||||
// registry.go:Heartbeat, so drain only fires when the workspace
|
||||
// itself reports spare capacity. That IS the session-ended
|
||||
// signal. The native_session SDK reports ActiveTasks=1 while in a
|
||||
// turn, ActiveTasks=0 when idle, and the next heartbeat after
|
||||
// idle triggers DrainQueueForWorkspace.
|
||||
//
|
||||
// So we collapse the two branches: both native_session and
|
||||
// non-native callers enqueue here. The native_session SDK's own
|
||||
// in-flight POST stays unaffected; the queued item drains on the
|
||||
// next post-idle heartbeat.
|
||||
idempotencyKey := extractIdempotencyKey(body)
|
||||
// Honor params.expires_in_seconds when the caller specifies one. Zero
|
||||
// (the unset default) → expiresAt = nil → infinite TTL preserved by
|
||||
@@ -113,6 +112,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
ctx, workspaceID, callerID, PriorityTask, body, a2aMethod, idempotencyKey, expiresAt,
|
||||
); qerr == nil {
|
||||
log.Printf("ProxyA2A: target %s busy — enqueued as %s (depth=%d)", workspaceID, qid, depth)
|
||||
if logActivity {
|
||||
h.logA2ABusyQueued(ctx, workspaceID, callerID, body, a2aMethod, durationMs)
|
||||
}
|
||||
respBody, _ := json.Marshal(gin.H{
|
||||
"queued": true,
|
||||
"queue_id": qid,
|
||||
@@ -126,6 +128,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// make delegation silently disappear.
|
||||
log.Printf("ProxyA2A: enqueue for %s failed (%v) — falling back to 503", workspaceID, qerr)
|
||||
}
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
@@ -136,6 +141,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
},
|
||||
}
|
||||
}
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusBadGateway,
|
||||
Response: gin.H{"error": "failed to reach workspace agent"},
|
||||
@@ -161,7 +169,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// canvas-chat-to-dead-workspace incident traces to exactly this gap.
|
||||
func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspaceID string) bool {
|
||||
var wsRuntime string
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'claude-code') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
|
||||
if isExternalLikeRuntime(wsRuntime) {
|
||||
return false
|
||||
}
|
||||
@@ -316,6 +324,33 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
})
|
||||
}
|
||||
|
||||
// logA2ABusyQueued records that a push attempt reached a live but busy
|
||||
// workspace and was durably queued for heartbeat drain.
|
||||
func (h *WorkspaceHandler) logA2ABusyQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, durationMs int) {
|
||||
var wsName string
|
||||
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||
if wsName == "" {
|
||||
wsName = workspaceID
|
||||
}
|
||||
summary := a2aMethod + " → " + wsName + " (queued: target busy)"
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: nilIfEmpty(callerID),
|
||||
TargetID: &workspaceID,
|
||||
Method: &a2aMethod,
|
||||
Summary: &summary,
|
||||
RequestBody: json.RawMessage(body),
|
||||
DurationMs: &durationMs,
|
||||
Status: "ok",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
|
||||
// 2xx/3xx responses) broadcasts an A2A_RESPONSE event so the frontend can
|
||||
// receive the reply without polling.
|
||||
@@ -388,31 +423,53 @@ func nilIfEmpty(s string) *string {
|
||||
// (their next /registry/register will mint their first token, after
|
||||
// which this branch never fires again for them).
|
||||
//
|
||||
// Post-RFC#637 addition: when the tokenless workspace is accompanied by
|
||||
// canvas or admin auth (same-origin request, admin bearer, or org-level
|
||||
// token), the caller is identified as a canvas-user identity rather than
|
||||
// a legacy peer agent. The returned isCanvasUser flag lets the A2A proxy
|
||||
// bypass CanCommunicate for human users, who sit outside the workspace
|
||||
// hierarchy.
|
||||
//
|
||||
// On auth failure this writes the 401 via c and returns an error so the
|
||||
// handler aborts without running the proxy.
|
||||
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) error {
|
||||
hasLive, err := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
|
||||
if err != nil {
|
||||
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) (isCanvasUser bool, err error) {
|
||||
hasLive, dbErr := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
|
||||
if dbErr != nil {
|
||||
// Fail-open here matches the heartbeat path — A2A caller auth is
|
||||
// defense-in-depth on top of access-control hierarchy, not the
|
||||
// sole gate on the secret material. A DB hiccup shouldn't take
|
||||
// the whole A2A path down.
|
||||
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, err)
|
||||
return nil
|
||||
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, dbErr)
|
||||
return false, nil
|
||||
}
|
||||
if !hasLive {
|
||||
return nil // legacy / pre-upgrade caller
|
||||
// Tokenless workspace — could be legacy/pre-upgrade caller or
|
||||
// canvas-user identity. Distinguish by request auth signals.
|
||||
if middleware.IsSameOriginCanvas(c) {
|
||||
return true, nil
|
||||
}
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok != "" {
|
||||
adminSecret := os.Getenv("ADMIN_TOKEN")
|
||||
if adminSecret != "" && subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
if _, _, _, err := orgtoken.Validate(ctx, db.DB, tok); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil // legacy / pre-upgrade caller
|
||||
}
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing caller auth token"})
|
||||
return errInvalidCallerToken
|
||||
return false, errInvalidCallerToken
|
||||
}
|
||||
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid caller auth token"})
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// errInvalidCallerToken is a sentinel for validateCallerToken's "missing
|
||||
|
||||
@@ -280,7 +280,7 @@ func TestProxyA2A_Upstream502_TriggersContainerDeadCheck(t *testing.T) {
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// maybeMarkContainerDead's runtime lookup, then the offline-flip UPDATE.
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-tunnel-dead").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
@@ -340,7 +340,7 @@ func TestProxyA2A_Upstream502_AliveAgent_PropagatesAsIs(t *testing.T) {
|
||||
expectBudgetCheck(mock, "ws-alive-502")
|
||||
mock.ExpectExec("INSERT INTO activity_logs").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// IsRunning runtime lookup runs but no UPDATE follows (running=true).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-alive-502").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
|
||||
@@ -1112,9 +1112,13 @@ func TestValidateCallerToken_LegacyCallerGrandfathered(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-legacy"); err != nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-legacy")
|
||||
if err != nil {
|
||||
t.Errorf("legacy caller should grandfather through; got %v", err)
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("legacy caller should NOT be identified as canvas user")
|
||||
}
|
||||
if w.Code != 200 {
|
||||
// gin default before c.JSON is 200; we want no error response written
|
||||
if w.Body.Len() != 0 {
|
||||
@@ -1136,10 +1140,13 @@ func TestValidateCallerToken_MissingTokenWhenOnFile(t *testing.T) {
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
// No Authorization header set
|
||||
|
||||
err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("authed workspace with missing token should NOT be canvas user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
@@ -1164,9 +1171,13 @@ func TestValidateCallerToken_InvalidToken(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
c.Request = req
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-authed"); err == nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad token")
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("authed workspace with bad token should NOT be canvas user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
@@ -1192,9 +1203,13 @@ func TestValidateCallerToken_ValidToken(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer goodtok")
|
||||
c.Request = req
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-authed"); err != nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-authed")
|
||||
if err != nil {
|
||||
t.Errorf("valid token should pass; got %v", err)
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("authed workspace with valid token should NOT be canvas user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
|
||||
@@ -1216,14 +1231,86 @@ func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer tok-for-A")
|
||||
c.Request = req
|
||||
|
||||
if err := validateCallerToken(context.Background(), c, "ws-b-attacker"); err == nil {
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-b-attacker")
|
||||
if err == nil {
|
||||
t.Fatal("token from A must not authenticate caller B")
|
||||
}
|
||||
if isCanvasUser {
|
||||
t.Errorf("cross-workspace token replay should NOT be identified as canvas user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerToken_CanvasUser_AdminToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Tokenless workspace
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs("ws-canvas-admin").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
t.Setenv("ADMIN_TOKEN", "admin-secret-42")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Authorization", "Bearer admin-secret-42")
|
||||
c.Request = req
|
||||
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-canvas-admin")
|
||||
if err != nil {
|
||||
t.Errorf("admin token should identify canvas user; got error: %v", err)
|
||||
}
|
||||
if !isCanvasUser {
|
||||
t.Errorf("admin token bearer should be identified as canvas user")
|
||||
}
|
||||
if w.Code != 200 || w.Body.Len() != 0 {
|
||||
t.Errorf("admin token path should not write a response body; got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerToken_CanvasUser_OrgToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Tokenless workspace
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs("ws-canvas-org").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// orgtoken.Validate lookup
|
||||
mock.ExpectQuery(`SELECT id, prefix, org_id FROM org_api_tokens WHERE token_hash = .* AND revoked_at IS NULL`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).AddRow("orgtok-1", "pref1234", "org-1"))
|
||||
mock.ExpectExec(`UPDATE org_api_tokens SET last_used_at`).
|
||||
WithArgs("orgtok-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest("POST", "/workspaces/x/a2a", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Authorization", "Bearer org-token-plaintext-xyz")
|
||||
c.Request = req
|
||||
|
||||
isCanvasUser, err := validateCallerToken(context.Background(), c, "ws-canvas-org")
|
||||
if err != nil {
|
||||
t.Errorf("org token should identify canvas user; got error: %v", err)
|
||||
}
|
||||
if !isCanvasUser {
|
||||
t.Errorf("org token bearer should be identified as canvas user")
|
||||
}
|
||||
if w.Code != 200 || w.Body.Len() != 0 {
|
||||
t.Errorf("org token path should not write a response body; got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Direct unit tests for normalizeA2APayload (extracted from proxyA2ARequest) ---
|
||||
|
||||
func TestNormalizeA2APayload_InvalidJSON(t *testing.T) {
|
||||
@@ -1779,6 +1866,58 @@ func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BusyEnqueueLogsQueuedNotFailure(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
mock.ExpectQuery(`INSERT INTO a2a_queue`).
|
||||
WithArgs("ws-busy", nil, PriorityTask, "{}", "message/send", nil, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11111111-1111-1111-1111-111111111111"))
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue`).
|
||||
WithArgs("ws-busy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-busy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Busy Target"))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
"ws-busy",
|
||||
"a2a_receive",
|
||||
nil,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
nil,
|
||||
nil,
|
||||
sqlmock.AnyArg(),
|
||||
"ok",
|
||||
nil,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
status, body, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-busy", "", []byte("{}"), "message/send",
|
||||
context.DeadlineExceeded, 180002, true,
|
||||
)
|
||||
if perr != nil {
|
||||
t.Fatalf("expected busy enqueue success, got proxy error: %+v", perr)
|
||||
}
|
||||
if status != http.StatusAccepted {
|
||||
t.Fatalf("got status %d, want 202", status)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"queued":true`)) {
|
||||
t.Fatalf("expected queued response body, got %s", string(body))
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations; busy enqueue must log status=ok, not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BuildError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -1815,9 +1954,9 @@ func TestMaybeMarkContainerDead_NilProvisioner(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-nilprov").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
|
||||
if got := handler.maybeMarkContainerDead(context.Background(), "ws-nilprov"); got {
|
||||
t.Error("expected false when provisioner is nil")
|
||||
@@ -1838,7 +1977,7 @@ func TestMaybeMarkContainerDead_CPOnly_NotRunning(t *testing.T) {
|
||||
cp := &fakeCPProv{running: false}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-saas-dead").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
@@ -1867,7 +2006,7 @@ func TestMaybeMarkContainerDead_CPOnly_Running(t *testing.T) {
|
||||
cp := &fakeCPProv{running: true}
|
||||
handler.SetCPProvisioner(cp)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-saas-alive").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
|
||||
@@ -1968,7 +2107,7 @@ func TestMaybeMarkContainerDead_ExternalRuntime(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, 'claude-code'\) FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
@@ -2354,7 +2493,7 @@ func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
|
||||
// ==================== a2aClient ResponseHeaderTimeout config ====================
|
||||
|
||||
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
const defaultTimeout = 180 * time.Second
|
||||
const defaultTimeout = 5 * time.Minute
|
||||
|
||||
// Default (unset env) — a2aClient was initialised at package load time.
|
||||
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
|
||||
@@ -2378,7 +2517,7 @@ func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
|
||||
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
|
||||
// Simulate what envx.Duration does with an invalid value.
|
||||
var fallback = 180 * time.Second
|
||||
var fallback = 5 * time.Minute
|
||||
override := fallback
|
||||
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
|
||||
@@ -333,7 +333,7 @@ func (h *WorkspaceHandler) DrainQueueForWorkspace(ctx context.Context, workspace
|
||||
}
|
||||
// logActivity=false: the original EnqueueA2A callsite already logged
|
||||
// the dispatch attempt; re-logging here would double-count events.
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, false)
|
||||
status, respBody, proxyErr := h.proxyA2ARequest(ctx, workspaceID, item.Body, callerID, false, false)
|
||||
|
||||
// 202 Accepted = the dispatch was itself queued again (target still busy).
|
||||
// That's not a failure — the queued item just stays queued naturally on
|
||||
|
||||
@@ -67,7 +67,213 @@ func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
|
||||
return &ActivityHandler{broadcaster: b}
|
||||
}
|
||||
|
||||
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
|
||||
// extractAttachmentsFromRequestBody walks a JSON-RPC a2a inbound body to
|
||||
// surface attachments (file/image/audio/video) as a flat `attachments[]`
|
||||
// projection so callers don't have to drill into the request_body shape
|
||||
// themselves.
|
||||
//
|
||||
// Two body shapes are walked in order:
|
||||
//
|
||||
// 1. a2a-sdk v1 message-part envelope (peer_agent inbound):
|
||||
//
|
||||
// {"jsonrpc":"2.0","method":"message/send","params":{
|
||||
// "message":{"parts":[
|
||||
// {"kind":"text", "text":"hi"},
|
||||
// {"kind":"file", "file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}},
|
||||
// {"kind":"image","file":{"uri":"workspace:bar.png","mime_type":"image/png","name":"bar.png"}},
|
||||
// ]}}}
|
||||
//
|
||||
// 2. canvas chat_upload_receive flat manifest (canvas_user upload):
|
||||
//
|
||||
// {"uri":"platform-pending:<ws>/<file>",
|
||||
// "name":"pasted.png",
|
||||
// "size":12345,
|
||||
// "file_id":"<uuid>",
|
||||
// "mimeType":"image/png"}
|
||||
//
|
||||
// The canvas upload pipe writes a single manifest directly at the
|
||||
// root of request_body (no JSON-RPC envelope) with camelCase
|
||||
// `mimeType`. We normalize to snake_case `mime_type` on the way out
|
||||
// so every downstream adaptor (channel / telegram / codex / hermes)
|
||||
// sees one wire shape regardless of which inbound shape produced it.
|
||||
//
|
||||
// Returns nil (omit-from-JSON) when the body has no attachments — the
|
||||
// `?include=peer_info` envelope projects this as an array iff non-empty.
|
||||
//
|
||||
// Defensive on every step: any missing key / wrong-shape value falls
|
||||
// through to the next arm or returns nil instead of panicking. The
|
||||
// activity_logs row could carry literally any JSON in request_body
|
||||
// (legacy formats, future formats); we only commit to the documented
|
||||
// shapes and silently skip anything else.
|
||||
func extractAttachmentsFromRequestBody(raw []byte) []map[string]interface{} {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return nil
|
||||
}
|
||||
if atts := extractAttachmentsFromMessageParts(body); len(atts) > 0 {
|
||||
return atts
|
||||
}
|
||||
if att := extractAttachmentFromFlatUploadManifest(body); att != nil {
|
||||
return []map[string]interface{}{att}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractAttachmentsFromMessageParts handles the a2a-sdk v1 shape:
|
||||
// body.params.message.parts[]. Walks file/image/audio parts; honors v1
|
||||
// `kind` and v0 `type` discriminators; accepts nested `.file` sub-object
|
||||
// or inlined uri/mime_type/name on the part itself.
|
||||
func extractAttachmentsFromMessageParts(body map[string]interface{}) []map[string]interface{} {
|
||||
params, ok := body["params"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
message, ok := params["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parts, ok := message["parts"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0)
|
||||
for _, p := range parts {
|
||||
part, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// a2a-sdk v1 uses "kind"; older v0 callers sent "type". Accept
|
||||
// both for the discriminator — same defensive read pattern as
|
||||
// the runtime-side extract_text helper.
|
||||
kind, _ := part["kind"].(string)
|
||||
if kind == "" {
|
||||
kind, _ = part["type"].(string)
|
||||
}
|
||||
if kind != "file" && kind != "image" && kind != "audio" {
|
||||
continue
|
||||
}
|
||||
// The file sub-object holds uri/mime_type/name. The a2a-sdk v1
|
||||
// shape nests under "file"; some legacy payloads inlined the
|
||||
// fields onto the part itself. Support both.
|
||||
var fileObj map[string]interface{}
|
||||
if f, ok := part["file"].(map[string]interface{}); ok {
|
||||
fileObj = f
|
||||
} else {
|
||||
fileObj = part
|
||||
}
|
||||
uri, _ := fileObj["uri"].(string)
|
||||
mimeType, _ := fileObj["mime_type"].(string)
|
||||
name, _ := fileObj["name"].(string)
|
||||
// At minimum we need either a uri or a name to be useful.
|
||||
// Empty-part entries are skipped (they're a malformed inbound
|
||||
// — surface nothing rather than emit a no-info placeholder).
|
||||
if uri == "" && name == "" {
|
||||
continue
|
||||
}
|
||||
att := map[string]interface{}{"kind": kind}
|
||||
if uri != "" {
|
||||
att["uri"] = uri
|
||||
}
|
||||
if mimeType != "" {
|
||||
att["mime_type"] = mimeType
|
||||
}
|
||||
if name != "" {
|
||||
att["name"] = name
|
||||
}
|
||||
out = append(out, att)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractAttachmentFromFlatUploadManifest handles the canvas
|
||||
// chat_upload_receive shape: a single upload manifest at the root of
|
||||
// request_body with no JSON-RPC envelope. Canvas uses camelCase
|
||||
// `mimeType`; we normalize to snake_case `mime_type` on emit so the
|
||||
// wire shape matches the message-parts arm. Kind is derived from the
|
||||
// mime prefix (image/* → "image", audio/* → "audio", video/* → "video",
|
||||
// anything else → "file") because the canvas upload row doesn't carry
|
||||
// an explicit discriminator. Returns nil if neither `uri` nor `file_id`
|
||||
// is present at the root (i.e. not a flat upload manifest).
|
||||
func extractAttachmentFromFlatUploadManifest(body map[string]interface{}) map[string]interface{} {
|
||||
uri, _ := body["uri"].(string)
|
||||
fileID, _ := body["file_id"].(string)
|
||||
if uri == "" && fileID == "" {
|
||||
return nil
|
||||
}
|
||||
mimeType, _ := body["mimeType"].(string)
|
||||
if mimeType == "" {
|
||||
// Defensive: future canvas versions might emit snake_case directly.
|
||||
mimeType, _ = body["mime_type"].(string)
|
||||
}
|
||||
name, _ := body["name"].(string)
|
||||
// Apply the same minimum-info rule as the message-parts arm: a
|
||||
// manifest with neither uri nor name is non-actionable; skip.
|
||||
if uri == "" && name == "" {
|
||||
return nil
|
||||
}
|
||||
att := map[string]interface{}{"kind": kindFromMimeType(mimeType)}
|
||||
if uri != "" {
|
||||
att["uri"] = uri
|
||||
}
|
||||
if mimeType != "" {
|
||||
att["mime_type"] = mimeType
|
||||
}
|
||||
if name != "" {
|
||||
att["name"] = name
|
||||
}
|
||||
return att
|
||||
}
|
||||
|
||||
// kindFromMimeType derives the attachment `kind` discriminator from a
|
||||
// MIME type. Used by the flat-upload-manifest arm where the source row
|
||||
// has no explicit kind field.
|
||||
func kindFromMimeType(mime string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(mime, "audio/"):
|
||||
return "audio"
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
// includeFlagSet returns true iff `flag` appears in the comma-separated
|
||||
// `?include=` query value. Whitespace around entries is tolerated.
|
||||
// Empty `include` returns false (existing back-compat shape).
|
||||
//
|
||||
// The comma-separable form lets future fields ("attachments_only",
|
||||
// "tool_trace_expanded", etc.) slot in without further URL-param creep.
|
||||
func includeFlagSet(includeQuery, flag string) bool {
|
||||
if includeQuery == "" || flag == "" {
|
||||
return false
|
||||
}
|
||||
for _, raw := range strings.Split(includeQuery, ",") {
|
||||
if strings.TrimSpace(raw) == flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=&include=
|
||||
//
|
||||
// The `include` query param is comma-separable; today the only flag is
|
||||
// `peer_info`, which enriches a2a_receive rows with `peer_name`,
|
||||
// `peer_role`, `agent_card_url`, and an `attachments[]` projection (see
|
||||
// extractAttachmentsFromRequestBody). It's additive + opt-in — existing
|
||||
// callers that don't pass `?include=peer_info` see the unchanged shape.
|
||||
// Surface for the layered enrichment that lets Claude Code channel
|
||||
// pushes carry full sender identity instead of bare UUIDs (sibling
|
||||
// repos: molecule-ai-workspace-runtime + molecule-mcp-claude-channel).
|
||||
//
|
||||
// since_secs filters to activity_logs.created_at >= NOW() - INTERVAL '$N seconds'.
|
||||
// Optional, additive — callers that don't pass it get today's behavior (the
|
||||
@@ -102,6 +308,8 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
sinceSecsStr := c.Query("since_secs")
|
||||
sinceID := c.Query("since_id")
|
||||
beforeTSStr := c.Query("before_ts") // optional RFC3339 — return rows strictly older than this timestamp
|
||||
include := c.Query("include") // comma-separated; today's only flag is "peer_info"
|
||||
includePeerInfo := includeFlagSet(include, "peer_info")
|
||||
|
||||
// Validate peer_id as a UUID at the trust boundary so a malformed
|
||||
// caller (the agent or a downstream MCP tool) can't smuggle SQL
|
||||
@@ -192,22 +400,60 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
usingCursor = true
|
||||
}
|
||||
|
||||
// Build query with optional filters
|
||||
query := `SELECT id, workspace_id, activity_type, source_id, target_id, method,
|
||||
summary, request_body, response_body, tool_trace, duration_ms, status, error_detail, created_at
|
||||
FROM activity_logs WHERE workspace_id = $1`
|
||||
// Build query with optional filters. When ?include=peer_info is set,
|
||||
// LEFT JOIN workspaces ON activity_logs.source_id = w.id so we can
|
||||
// surface w.name + w.role on the row. LEFT (not INNER) is required
|
||||
// for two reasons:
|
||||
// 1. Canvas rows have source_id IS NULL — those must still appear
|
||||
// in the result set (with NULL peer_name/peer_role).
|
||||
// 2. A peer workspace may have been deleted since the row was
|
||||
// written (no FK constraint on activity_logs.source_id) —
|
||||
// LEFT JOIN preserves the activity row with NULL peer fields
|
||||
// rather than silently dropping the row.
|
||||
//
|
||||
// agent_card_url is NOT pulled from the workspaces table; it's
|
||||
// computed server-side from externalPlatformURL + source_id at
|
||||
// projection time (mirrors molecule-ai-workspace-runtime
|
||||
// a2a_client._agent_card_url_for which constructs
|
||||
// {PLATFORM_URL}/registry/discover/{peer_id}).
|
||||
//
|
||||
// Column qualification (`activity_logs.<col>`) is added ONLY when
|
||||
// the JOIN is present — disambiguates `id` / `created_at` which
|
||||
// exist in both tables. When the JOIN is absent, unqualified
|
||||
// column references preserve the exact wire-shape existing callers
|
||||
// + existing test fixtures expect (back-compat).
|
||||
actCol := ""
|
||||
if includePeerInfo {
|
||||
actCol = "activity_logs."
|
||||
}
|
||||
selectClause := `SELECT ` + actCol + `id, ` + actCol + `workspace_id, ` + actCol + `activity_type, ` +
|
||||
actCol + `source_id, ` + actCol + `target_id, ` + actCol + `method, ` +
|
||||
actCol + `summary, ` + actCol + `request_body, ` + actCol + `response_body, ` +
|
||||
actCol + `tool_trace, ` + actCol + `duration_ms, ` + actCol + `status, ` +
|
||||
actCol + `error_detail, ` + actCol + `created_at`
|
||||
fromClause := ` FROM activity_logs`
|
||||
if includePeerInfo {
|
||||
selectClause += `, w.name AS peer_name, w.role AS peer_role`
|
||||
fromClause += ` LEFT JOIN workspaces w ON w.id = activity_logs.source_id`
|
||||
}
|
||||
query := selectClause + fromClause + ` WHERE ` + actCol + `workspace_id = $1`
|
||||
args := []interface{}{workspaceID}
|
||||
argIdx := 2
|
||||
|
||||
// WHERE/ORDER column refs use the same `actCol` qualifier prefix
|
||||
// computed above — empty string when no JOIN (back-compat with
|
||||
// existing wire shape + sqlmock-regex test fixtures), or
|
||||
// `activity_logs.` when LEFT JOIN'd (disambiguates `id` /
|
||||
// `created_at` between the two tables).
|
||||
if activityType != "" {
|
||||
query += fmt.Sprintf(" AND activity_type = $%d", argIdx)
|
||||
query += fmt.Sprintf(" AND "+actCol+"activity_type = $%d", argIdx)
|
||||
args = append(args, activityType)
|
||||
argIdx++
|
||||
}
|
||||
if source == "canvas" {
|
||||
query += " AND source_id IS NULL"
|
||||
query += " AND " + actCol + "source_id IS NULL"
|
||||
} else if source == "agent" {
|
||||
query += " AND source_id IS NOT NULL"
|
||||
query += " AND " + actCol + "source_id IS NOT NULL"
|
||||
} else if source != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'canvas' or 'agent'"})
|
||||
return
|
||||
@@ -224,7 +470,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
// and avoids duplicate parameter binding (some drivers reject the
|
||||
// same arg slot reused, ours is fine but the explicit form is
|
||||
// clearer to read and matches the rest of the builder.)
|
||||
query += fmt.Sprintf(" AND (source_id = $%d OR target_id = $%d)", argIdx, argIdx)
|
||||
query += fmt.Sprintf(" AND ("+actCol+"source_id = $%d OR "+actCol+"target_id = $%d)", argIdx, argIdx)
|
||||
args = append(args, peerID)
|
||||
argIdx++
|
||||
}
|
||||
@@ -232,7 +478,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
// Strictly older — never replay a row with the exact same
|
||||
// timestamp, mirrors the `created_at > cursorTime` shape
|
||||
// `since_id` uses for forward paging.
|
||||
query += fmt.Sprintf(" AND created_at < $%d", argIdx)
|
||||
query += fmt.Sprintf(" AND "+actCol+"created_at < $%d", argIdx)
|
||||
args = append(args, beforeTS)
|
||||
argIdx++
|
||||
}
|
||||
@@ -241,13 +487,13 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
// interpolated into the SQL string. `make_interval(secs => $N)`
|
||||
// avoids the lib/pq quirk where INTERVAL '$N seconds' won't
|
||||
// substitute a placeholder inside the literal.
|
||||
query += fmt.Sprintf(" AND created_at >= NOW() - make_interval(secs => $%d)", argIdx)
|
||||
query += fmt.Sprintf(" AND "+actCol+"created_at >= NOW() - make_interval(secs => $%d)", argIdx)
|
||||
args = append(args, sinceSecs)
|
||||
argIdx++
|
||||
}
|
||||
if usingCursor {
|
||||
// Strictly after — never replay the cursor row itself.
|
||||
query += fmt.Sprintf(" AND created_at > $%d", argIdx)
|
||||
query += fmt.Sprintf(" AND "+actCol+"created_at > $%d", argIdx)
|
||||
args = append(args, cursorTime)
|
||||
argIdx++
|
||||
}
|
||||
@@ -257,9 +503,9 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
// since_id) keeps DESC — that's the canvas/UI shape and changing it
|
||||
// would surprise existing callers.
|
||||
if usingCursor {
|
||||
query += fmt.Sprintf(" ORDER BY created_at ASC LIMIT $%d", argIdx)
|
||||
query += fmt.Sprintf(" ORDER BY "+actCol+"created_at ASC LIMIT $%d", argIdx)
|
||||
} else {
|
||||
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", argIdx)
|
||||
query += fmt.Sprintf(" ORDER BY "+actCol+"created_at DESC LIMIT $%d", argIdx)
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
@@ -272,6 +518,14 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// agent_card_url base computed once per request so we don't pay the
|
||||
// header-read cost per row. Only meaningful when includePeerInfo is
|
||||
// set; the empty string here is harmless when the flag is off.
|
||||
var platformBase string
|
||||
if includePeerInfo {
|
||||
platformBase = externalPlatformURL(c)
|
||||
}
|
||||
|
||||
activities := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, wsID, actType, status string
|
||||
@@ -279,10 +533,23 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
var reqBody, respBody, toolTrace []byte
|
||||
var durationMs *int
|
||||
var createdAt time.Time
|
||||
// LEFT JOIN'd peer columns — pointer-string so a NULL row
|
||||
// (canvas message OR deleted peer workspace) decodes as nil
|
||||
// rather than empty-string. Only scanned when includePeerInfo
|
||||
// is set (matched against the SELECT clause above).
|
||||
var peerName, peerRole *string
|
||||
|
||||
if err := rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
|
||||
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt); err != nil {
|
||||
log.Printf("Activity scan error: %v", err)
|
||||
var scanErr error
|
||||
if includePeerInfo {
|
||||
scanErr = rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
|
||||
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt,
|
||||
&peerName, &peerRole)
|
||||
} else {
|
||||
scanErr = rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
|
||||
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt)
|
||||
}
|
||||
if scanErr != nil {
|
||||
log.Printf("Activity scan error: %v", scanErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -308,6 +575,39 @@ func (h *ActivityHandler) List(c *gin.Context) {
|
||||
if toolTrace != nil {
|
||||
entry["tool_trace"] = json.RawMessage(toolTrace)
|
||||
}
|
||||
|
||||
// peer_info enrichment (per ?include=peer_info). Only emit the
|
||||
// new fields when the flag is set — back-compat for callers
|
||||
// that don't request it.
|
||||
if includePeerInfo {
|
||||
// peer_name / peer_role: emit only when present (canvas
|
||||
// rows have source_id IS NULL → peer_name is NULL by JOIN;
|
||||
// also a peer workspace may have been deleted since the
|
||||
// row was written → same NULL outcome). Omit-when-absent
|
||||
// matches the Layer 3 adaptor's "spread when present"
|
||||
// pattern; canvas_user rows legitimately have no peer_*.
|
||||
if peerName != nil && *peerName != "" {
|
||||
entry["peer_name"] = *peerName
|
||||
}
|
||||
if peerRole != nil && *peerRole != "" {
|
||||
entry["peer_role"] = *peerRole
|
||||
}
|
||||
// agent_card_url: constructed server-side from
|
||||
// externalPlatformURL + source_id. Mirrors the runtime-
|
||||
// side helper a2a_client._agent_card_url_for which builds
|
||||
// {PLATFORM_URL}/registry/discover/{peer_id}. Only set
|
||||
// when source_id is present + non-empty.
|
||||
if sourceID != nil && *sourceID != "" && platformBase != "" {
|
||||
entry["agent_card_url"] = platformBase + "/registry/discover/" + *sourceID
|
||||
}
|
||||
// attachments: flatten file/image/audio parts from the
|
||||
// request_body. nil when none — only project when
|
||||
// non-empty so the omit-when-absent rule holds.
|
||||
if atts := extractAttachmentsFromRequestBody(reqBody); len(atts) > 0 {
|
||||
entry["attachments"] = atts
|
||||
}
|
||||
}
|
||||
|
||||
activities = append(activities, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Tests for the `?include=peer_info` activity-feed enrichment.
|
||||
//
|
||||
// The enrichment is additive + opt-in. When the flag is absent, the
|
||||
// existing tests (TestActivityList_SourceCanvas, etc.) prove the wire
|
||||
// shape is unchanged. These tests prove:
|
||||
// - When the flag IS set, the LEFT JOIN is issued and the SELECT
|
||||
// adds w.name + w.role.
|
||||
// - peer_name / peer_role surface from the joined row.
|
||||
// - agent_card_url is composed server-side from
|
||||
// externalPlatformURL + source_id and appears for non-canvas rows
|
||||
// (source_id present).
|
||||
// - attachments[] is projected from request_body.params.message.parts
|
||||
// for file/image/audio parts.
|
||||
// - Canvas rows (source_id NULL) do NOT get peer_name / peer_role /
|
||||
// agent_card_url, but DO still appear in the result set (LEFT JOIN
|
||||
// preserves them with NULL peer fields).
|
||||
// - The `include` query param is comma-separable and only recognizes
|
||||
// known flags.
|
||||
|
||||
// ---------- includeFlagSet helper unit tests ----------
|
||||
|
||||
func TestIncludeFlagSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
flag string
|
||||
want bool
|
||||
}{
|
||||
{"", "peer_info", false},
|
||||
{"peer_info", "peer_info", true},
|
||||
{"peer_info,attachments", "peer_info", true},
|
||||
{"attachments,peer_info", "peer_info", true},
|
||||
{"attachments , peer_info ", "peer_info", true},
|
||||
{"peer_infos", "peer_info", false},
|
||||
{"peerinfo", "peer_info", false},
|
||||
{"peer_info", "", false},
|
||||
{",,", "peer_info", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := includeFlagSet(tc.query, tc.flag)
|
||||
if got != tc.want {
|
||||
t.Errorf("includeFlagSet(%q, %q) = %v, want %v", tc.query, tc.flag, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- extractAttachmentsFromRequestBody unit tests ----------
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_Empty(t *testing.T) {
|
||||
if got := extractAttachmentsFromRequestBody(nil); got != nil {
|
||||
t.Errorf("nil body: want nil, got %v", got)
|
||||
}
|
||||
if got := extractAttachmentsFromRequestBody([]byte("")); got != nil {
|
||||
t.Errorf("empty body: want nil, got %v", got)
|
||||
}
|
||||
if got := extractAttachmentsFromRequestBody([]byte("not json")); got != nil {
|
||||
t.Errorf("non-json body: want nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_NoAttachments(t *testing.T) {
|
||||
// Text-only message: no file/image/audio parts → nil
|
||||
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[{"kind":"text","text":"hi"}]}}}`)
|
||||
if got := extractAttachmentsFromRequestBody(body); got != nil {
|
||||
t.Errorf("text-only: want nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FileKindV1(t *testing.T) {
|
||||
// a2a-sdk v1 shape: kind=file, file:{uri,mime_type,name}
|
||||
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
|
||||
{"kind":"text","text":"see attached"},
|
||||
{"kind":"file","file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}}
|
||||
]}}}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0]["kind"] != "file" {
|
||||
t.Errorf("kind: want file, got %v", atts[0]["kind"])
|
||||
}
|
||||
if atts[0]["uri"] != "workspace:foo.pdf" {
|
||||
t.Errorf("uri mismatch: %v", atts[0]["uri"])
|
||||
}
|
||||
if atts[0]["mime_type"] != "application/pdf" {
|
||||
t.Errorf("mime_type mismatch: %v", atts[0]["mime_type"])
|
||||
}
|
||||
if atts[0]["name"] != "foo.pdf" {
|
||||
t.Errorf("name mismatch: %v", atts[0]["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_ImageAndAudio(t *testing.T) {
|
||||
// Mixed image + audio parts; both surface
|
||||
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
|
||||
{"kind":"image","file":{"uri":"workspace:a.png","mime_type":"image/png","name":"a.png"}},
|
||||
{"kind":"audio","file":{"uri":"workspace:b.mp3","mime_type":"audio/mpeg","name":"b.mp3"}}
|
||||
]}}}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 2 {
|
||||
t.Fatalf("want 2 attachments, got %d", len(atts))
|
||||
}
|
||||
if atts[0]["kind"] != "image" || atts[1]["kind"] != "audio" {
|
||||
t.Errorf("kind order: got %v / %v", atts[0]["kind"], atts[1]["kind"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_LegacyV0TypeDiscriminator(t *testing.T) {
|
||||
// Legacy v0 shape: type=file (not kind), inlined fields (no nested .file)
|
||||
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
|
||||
{"type":"file","uri":"workspace:legacy.txt","mime_type":"text/plain","name":"legacy.txt"}
|
||||
]}}}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0]["kind"] != "file" || atts[0]["uri"] != "workspace:legacy.txt" || atts[0]["name"] != "legacy.txt" {
|
||||
t.Errorf("v0 part not surfaced: %v", atts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_SkipsEmptyParts(t *testing.T) {
|
||||
// A "file" part with no uri AND no name is malformed — skip rather
|
||||
// than emit a no-info entry.
|
||||
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
|
||||
{"kind":"file","file":{}},
|
||||
{"kind":"file","file":{"name":"only-name.bin"}}
|
||||
]}}}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment (the named one), got %d", len(atts))
|
||||
}
|
||||
if atts[0]["name"] != "only-name.bin" {
|
||||
t.Errorf("expected only-name.bin, got %v", atts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_MalformedShape(t *testing.T) {
|
||||
// Various malformed shapes return nil (defensive)
|
||||
for _, b := range []string{
|
||||
`{}`,
|
||||
`{"params":{}}`,
|
||||
`{"params":{"message":{}}}`,
|
||||
`{"params":{"message":{"parts":"not-a-list"}}}`,
|
||||
`{"params":{"message":{"parts":[null,42,"string"]}}}`,
|
||||
} {
|
||||
if got := extractAttachmentsFromRequestBody([]byte(b)); got != nil {
|
||||
t.Errorf("body %q: want nil, got %v", b, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Activity List ?include=peer_info handler tests ----------
|
||||
|
||||
func TestActivityList_IncludePeerInfo_IssuesLeftJoin(t *testing.T) {
|
||||
// When ?include=peer_info is set, the query must:
|
||||
// 1. SELECT include w.name + w.role aliased as peer_name/peer_role
|
||||
// 2. FROM contains LEFT JOIN workspaces w ON w.id = activity_logs.source_id
|
||||
// 3. WHERE uses qualified activity_logs.workspace_id (disambiguates
|
||||
// from workspaces.id post-JOIN)
|
||||
//
|
||||
// Pin all three so a future refactor can't silently drop the JOIN or
|
||||
// the alias and have the test still pass.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
peerID := "11111111-2222-3333-4444-555555555555"
|
||||
mock.ExpectQuery(
|
||||
`SELECT .+w\.name AS peer_name, w\.role AS peer_role FROM activity_logs LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id WHERE activity_logs\.workspace_id = .+`,
|
||||
).
|
||||
WithArgs("ws-1", 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id",
|
||||
"method", "summary", "request_body", "response_body",
|
||||
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
|
||||
"peer_name", "peer_role",
|
||||
}).
|
||||
AddRow("act-1", "ws-1", "a2a_receive", peerID, "ws-1",
|
||||
"message/send", "Agent message: hello",
|
||||
[]byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[{"kind":"text","text":"hello"}]}}}`),
|
||||
nil, nil, nil, "ok", nil, time.Now(),
|
||||
"Production Manager", "product manager"))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
|
||||
c.Request.Host = "platform.test"
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
handler.List(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("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(resp))
|
||||
}
|
||||
r := resp[0]
|
||||
if r["peer_name"] != "Production Manager" {
|
||||
t.Errorf("peer_name: got %v", r["peer_name"])
|
||||
}
|
||||
if r["peer_role"] != "product manager" {
|
||||
t.Errorf("peer_role: got %v", r["peer_role"])
|
||||
}
|
||||
wantURL := "https://platform.test/registry/discover/" + peerID
|
||||
if r["agent_card_url"] != wantURL {
|
||||
t.Errorf("agent_card_url: got %v, want %v", r["agent_card_url"], wantURL)
|
||||
}
|
||||
// Text-only message has no attachments → omit from envelope
|
||||
if _, present := r["attachments"]; present {
|
||||
t.Errorf("attachments should be omitted on text-only row; got %v", r["attachments"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityList_IncludePeerInfo_CanvasRowHasNoPeerFields(t *testing.T) {
|
||||
// LEFT JOIN preserves canvas rows (source_id NULL) but their
|
||||
// peer_name/peer_role come back as NULL — must omit from the
|
||||
// envelope (not emit empty strings or null literals).
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery(
|
||||
`LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id`,
|
||||
).
|
||||
WithArgs("ws-1", 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id",
|
||||
"method", "summary", "request_body", "response_body",
|
||||
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
|
||||
"peer_name", "peer_role",
|
||||
}).
|
||||
// source_id NULL = canvas message; peer columns also NULL.
|
||||
AddRow("act-canvas", "ws-1", "a2a_receive", nil, "ws-1",
|
||||
"notify", "User said hi",
|
||||
[]byte(`{"params":{"message":{"parts":[{"kind":"text","text":"hi"}]}}}`),
|
||||
nil, nil, nil, "ok", nil, time.Now(),
|
||||
nil, nil))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
|
||||
handler.List(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("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(resp))
|
||||
}
|
||||
r := resp[0]
|
||||
for _, k := range []string{"peer_name", "peer_role", "agent_card_url"} {
|
||||
if _, present := r[k]; present {
|
||||
t.Errorf("%s should be absent on canvas row; got %v", k, r[k])
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityList_IncludePeerInfo_AttachmentsSurfaceFromRequestBody(t *testing.T) {
|
||||
// A peer_agent message with an inline file attachment must have
|
||||
// attachments[] populated on the envelope.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
peerID := "11111111-2222-3333-4444-555555555555"
|
||||
mock.ExpectQuery(`LEFT JOIN workspaces`).
|
||||
WithArgs("ws-1", 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id",
|
||||
"method", "summary", "request_body", "response_body",
|
||||
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
|
||||
"peer_name", "peer_role",
|
||||
}).
|
||||
AddRow("act-with-file", "ws-1", "a2a_receive", peerID, "ws-1",
|
||||
"message/send", "Agent message: see attached",
|
||||
[]byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
|
||||
{"kind":"text","text":"see attached"},
|
||||
{"kind":"file","file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}}
|
||||
]}}}`),
|
||||
nil, nil, nil, "ok", nil, time.Now(),
|
||||
"Code Reviewer", "code reviewer"))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
|
||||
handler.List(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("parse: %v", err)
|
||||
}
|
||||
r := resp[0]
|
||||
atts, ok := r["attachments"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("attachments missing or wrong type: %T %v", r["attachments"], r["attachments"])
|
||||
}
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d: %v", len(atts), atts)
|
||||
}
|
||||
att := atts[0].(map[string]interface{})
|
||||
if att["kind"] != "file" || att["uri"] != "workspace:foo.pdf" || att["name"] != "foo.pdf" {
|
||||
t.Errorf("attachment shape: %v", att)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityList_IncludePeerInfo_Unset_NoJoinNoExtraFields(t *testing.T) {
|
||||
// Back-compat — when ?include=peer_info is NOT passed, the SELECT
|
||||
// uses unqualified column refs (no `activity_logs.` prefix) AND no
|
||||
// JOIN. Existing tests pass this implicitly; this test pins it
|
||||
// explicitly so a future refactor that accidentally turns the JOIN
|
||||
// always-on gets caught.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
// Regex pinned: "FROM activity_logs WHERE workspace_id" — no JOIN
|
||||
// keyword between FROM and WHERE; no `activity_logs.` qualifier on
|
||||
// workspace_id.
|
||||
mock.ExpectQuery(`SELECT id, workspace_id,.+ FROM activity_logs WHERE workspace_id = .+`).
|
||||
WithArgs("ws-1", 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id",
|
||||
"method", "summary", "request_body", "response_body",
|
||||
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
|
||||
}).
|
||||
AddRow("act-1", "ws-1", "a2a_receive", "11111111-2222-3333-4444-555555555555", "ws-1",
|
||||
"message/send", "Hello",
|
||||
nil, nil, nil, nil, "ok", nil, time.Now()))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity", nil)
|
||||
handler.List(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("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(resp))
|
||||
}
|
||||
// Confirm no peer_info enrichment leaks into the default envelope.
|
||||
for _, k := range []string{"peer_name", "peer_role", "agent_card_url", "attachments"} {
|
||||
if _, present := resp[0][k]; present {
|
||||
t.Errorf("%s must NOT appear without ?include=peer_info; got %v", k, resp[0][k])
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityList_IncludePeerInfo_UnknownFlagIgnored(t *testing.T) {
|
||||
// ?include=bogus must NOT issue the JOIN — only the recognized
|
||||
// `peer_info` flag triggers enrichment. The unknown flag is silently
|
||||
// ignored (additive, opt-in convention).
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery(`SELECT id, workspace_id,.+ FROM activity_logs WHERE workspace_id = .+`).
|
||||
WithArgs("ws-1", 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id",
|
||||
"method", "summary", "request_body", "response_body",
|
||||
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
|
||||
}))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=bogus", nil)
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- flat upload manifest (chat_upload_receive) tests ----------
|
||||
|
||||
func TestKindFromMimeType(t *testing.T) {
|
||||
cases := []struct {
|
||||
mime string
|
||||
want string
|
||||
}{
|
||||
{"image/png", "image"},
|
||||
{"image/jpeg", "image"},
|
||||
{"image/", "image"}, // prefix-only is still image
|
||||
{"audio/mpeg", "audio"},
|
||||
{"audio/wav", "audio"},
|
||||
{"video/mp4", "video"},
|
||||
{"video/webm", "video"},
|
||||
{"application/pdf", "file"},
|
||||
{"text/plain", "file"},
|
||||
{"", "file"},
|
||||
{"unknown", "file"},
|
||||
{"image", "file"}, // no slash → not a prefix match
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := kindFromMimeType(tc.mime); got != tc.want {
|
||||
t.Errorf("kindFromMimeType(%q) = %q, want %q", tc.mime, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_Image(t *testing.T) {
|
||||
// Canvas chat_upload_receive shape: flat manifest at request_body
|
||||
// root with camelCase mimeType. The empirical example was a PNG
|
||||
// pasted into the canvas; surfaces here with kind=image,
|
||||
// mime_type=image/png (snake-case normalized), uri preserved.
|
||||
body := []byte(`{
|
||||
"uri":"platform-pending:091a9180-/26111d48-",
|
||||
"name":"pasted-2026-05-21T23-12-25-0-0.png",
|
||||
"size":677133,
|
||||
"file_id":"26111d48-",
|
||||
"mimeType":"image/png"
|
||||
}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d: %v", len(atts), atts)
|
||||
}
|
||||
att := atts[0]
|
||||
if att["kind"] != "image" {
|
||||
t.Errorf("kind: want image, got %v", att["kind"])
|
||||
}
|
||||
if att["uri"] != "platform-pending:091a9180-/26111d48-" {
|
||||
t.Errorf("uri: %v", att["uri"])
|
||||
}
|
||||
if att["mime_type"] != "image/png" {
|
||||
t.Errorf("mime_type normalization (camelCase→snake_case) failed: %v", att["mime_type"])
|
||||
}
|
||||
if att["name"] != "pasted-2026-05-21T23-12-25-0-0.png" {
|
||||
t.Errorf("name: %v", att["name"])
|
||||
}
|
||||
// camelCase `mimeType` MUST NOT leak into the projected envelope —
|
||||
// only snake_case `mime_type` is the wire convention.
|
||||
if _, present := att["mimeType"]; present {
|
||||
t.Errorf("camelCase mimeType leaked into envelope: %v", att)
|
||||
}
|
||||
if _, present := att["file_id"]; present {
|
||||
t.Errorf("file_id should not be surfaced on the attachment envelope (it's a canvas-internal id): %v", att)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_Audio(t *testing.T) {
|
||||
body := []byte(`{"uri":"platform-pending:ws/file","name":"voice.mp3","file_id":"abc","mimeType":"audio/mpeg"}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 || atts[0]["kind"] != "audio" {
|
||||
t.Fatalf("want audio kind, got %v", atts)
|
||||
}
|
||||
if atts[0]["mime_type"] != "audio/mpeg" {
|
||||
t.Errorf("mime_type: %v", atts[0]["mime_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_Video(t *testing.T) {
|
||||
body := []byte(`{"uri":"platform-pending:ws/file","name":"clip.mp4","file_id":"abc","mimeType":"video/mp4"}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 || atts[0]["kind"] != "video" {
|
||||
t.Fatalf("want video kind, got %v", atts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_GenericFile(t *testing.T) {
|
||||
// application/pdf has no image/audio/video prefix → kind=file
|
||||
body := []byte(`{"uri":"platform-pending:ws/file","name":"doc.pdf","file_id":"abc","mimeType":"application/pdf"}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 || atts[0]["kind"] != "file" {
|
||||
t.Fatalf("want file kind, got %v", atts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_NoMimeFallsToFile(t *testing.T) {
|
||||
// No mimeType at all — kind defaults to "file", mime_type omitted.
|
||||
body := []byte(`{"uri":"platform-pending:ws/file","name":"unknown.bin","file_id":"abc"}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0]["kind"] != "file" {
|
||||
t.Errorf("kind: want file (default), got %v", atts[0]["kind"])
|
||||
}
|
||||
if _, present := atts[0]["mime_type"]; present {
|
||||
t.Errorf("mime_type should be omitted when source has none, got %v", atts[0]["mime_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_SnakeCaseMimeTypeAccepted(t *testing.T) {
|
||||
// Defensive: a future canvas version (or non-canvas caller) that
|
||||
// already emits snake_case mime_type should still be parsed.
|
||||
body := []byte(`{"uri":"u","name":"n.png","mime_type":"image/png"}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0]["mime_type"] != "image/png" || atts[0]["kind"] != "image" {
|
||||
t.Errorf("snake_case mime_type not honored: %v", atts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_FileIDOnlyIsSkipped(t *testing.T) {
|
||||
// file_id alone (no uri AND no name) is non-actionable — the
|
||||
// downstream adaptor can't render a discoverable file from just an
|
||||
// internal canvas id. Skip per the same minimum-info rule the
|
||||
// message-parts arm applies to empty parts.
|
||||
body := []byte(`{"file_id":"orphan-uuid","mimeType":"image/png"}`)
|
||||
if got := extractAttachmentsFromRequestBody(body); got != nil {
|
||||
t.Errorf("file_id-only manifest must be skipped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_FlatUpload_NameOnlyIsKept(t *testing.T) {
|
||||
// Symmetric with the message-parts arm: a name without uri is still
|
||||
// useful (the downstream adaptor can render "user uploaded foo.png").
|
||||
body := []byte(`{"name":"only-name.bin","file_id":"abc","mimeType":"application/octet-stream"}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0]["name"] != "only-name.bin" {
|
||||
t.Errorf("name not preserved: %v", atts[0])
|
||||
}
|
||||
if _, present := atts[0]["uri"]; present {
|
||||
t.Errorf("uri should be omitted when absent in source, got %v", atts[0]["uri"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttachmentsFromRequestBody_MessagePartsTakesPrecedenceOverFlat(t *testing.T) {
|
||||
// If a single request_body somehow has BOTH params.message.parts[]
|
||||
// AND top-level uri/file_id (a pathological inbound), the
|
||||
// message-parts arm wins — that's the documented inbound shape and
|
||||
// it's been the only one historically extracted. The flat arm is a
|
||||
// fallback for shapes that have NO parts.
|
||||
body := []byte(`{
|
||||
"uri":"platform-pending:should-not-win",
|
||||
"file_id":"x",
|
||||
"mimeType":"image/png",
|
||||
"params":{"message":{"parts":[
|
||||
{"kind":"file","file":{"uri":"workspace:should-win.pdf","mime_type":"application/pdf","name":"win.pdf"}}
|
||||
]}}
|
||||
}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment (from parts[]), got %d: %v", len(atts), atts)
|
||||
}
|
||||
if atts[0]["uri"] != "workspace:should-win.pdf" {
|
||||
t.Errorf("message-parts arm did not take precedence: %v", atts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityList_IncludePeerInfo_ChatUploadReceiveCanvasRow(t *testing.T) {
|
||||
// Wire-level integration: a canvas chat_upload_receive row (canvas
|
||||
// user pasted an image) with source_id NULL (canvas message), flat
|
||||
// upload manifest at request_body root. The `?include=peer_info`
|
||||
// projection must surface attachments[] populated from the flat-
|
||||
// upload-manifest arm while peer_name / peer_role / agent_card_url
|
||||
// remain absent (canvas row has no peer).
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery(`LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id`).
|
||||
WithArgs("ws-1", 100).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "activity_type", "source_id", "target_id",
|
||||
"method", "summary", "request_body", "response_body",
|
||||
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
|
||||
"peer_name", "peer_role",
|
||||
}).
|
||||
// Empirical shape from 2026-05-21 ~23:12Z agents-team canvas paste.
|
||||
AddRow("act-upload", "ws-1", "chat_upload_receive", nil, "ws-1",
|
||||
"chat_upload_receive", "Canvas upload: pasted-2026-05-21T23-12-25-0-0.png",
|
||||
[]byte(`{
|
||||
"uri":"platform-pending:091a9180-b303-4a20-aefe-3a4a675b8aa4/26111d48-aaaa-bbbb-cccc-dddddddddddd",
|
||||
"name":"pasted-2026-05-21T23-12-25-0-0.png",
|
||||
"size":677133,
|
||||
"file_id":"26111d48-aaaa-bbbb-cccc-dddddddddddd",
|
||||
"mimeType":"image/png"
|
||||
}`),
|
||||
nil, nil, nil, "ok", nil, time.Now(),
|
||||
nil, nil))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
|
||||
handler.List(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("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(resp))
|
||||
}
|
||||
r := resp[0]
|
||||
// Canvas row → no peer fields.
|
||||
for _, k := range []string{"peer_name", "peer_role", "agent_card_url"} {
|
||||
if _, present := r[k]; present {
|
||||
t.Errorf("%s must NOT appear on canvas upload row; got %v", k, r[k])
|
||||
}
|
||||
}
|
||||
// attachments[] populated from the flat-upload arm.
|
||||
atts, ok := r["attachments"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("attachments missing or wrong type: %T %v", r["attachments"], r["attachments"])
|
||||
}
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("want 1 attachment from flat manifest, got %d: %v", len(atts), atts)
|
||||
}
|
||||
att := atts[0].(map[string]interface{})
|
||||
if att["kind"] != "image" {
|
||||
t.Errorf("kind: want image (image/png prefix), got %v", att["kind"])
|
||||
}
|
||||
if att["mime_type"] != "image/png" {
|
||||
t.Errorf("mime_type wire shape: want snake_case image/png, got %v", att["mime_type"])
|
||||
}
|
||||
if att["uri"] != "platform-pending:091a9180-b303-4a20-aefe-3a4a675b8aa4/26111d48-aaaa-bbbb-cccc-dddddddddddd" {
|
||||
t.Errorf("uri preserved verbatim: got %v", att["uri"])
|
||||
}
|
||||
if att["name"] != "pasted-2026-05-21T23-12-25-0-0.png" {
|
||||
t.Errorf("name: %v", att["name"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity test using the existing test broadcaster setup — verifies the
|
||||
// extractAttachments helper round-trips through json.Marshal cleanly
|
||||
// (no map ordering issues, no type-coercion surprises).
|
||||
func TestExtractAttachmentsFromRequestBody_RoundTripsThroughJSON(t *testing.T) {
|
||||
body := []byte(`{"params":{"message":{"parts":[{"kind":"file","file":{"uri":"workspace:r.bin","mime_type":"application/octet-stream","name":"r.bin"}}]}}}`)
|
||||
atts := extractAttachmentsFromRequestBody(body)
|
||||
b, err := json.Marshal(atts)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(decoded) != 1 || decoded[0]["uri"] != "workspace:r.bin" {
|
||||
t.Fatalf("round-trip mismatch: %v", decoded)
|
||||
}
|
||||
_ = fmt.Sprintf // keep fmt import live if test trimming removes usage
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,19 +15,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// envMemoryV2Cutover gates whether admin export/import routes through
|
||||
// the v2 plugin (PR-8 / RFC #2728). When unset, the legacy direct-DB
|
||||
// path runs unchanged so operators who haven't enabled the plugin
|
||||
// keep working.
|
||||
const envMemoryV2Cutover = "MEMORY_V2_CUTOVER"
|
||||
|
||||
// AdminMemoriesHandler provides bulk export/import of agent memories for
|
||||
// backup and restore across Docker rebuilds (issue #1051).
|
||||
//
|
||||
// PR-8 (RFC #2728): when wired with the v2 plugin via WithMemoryV2 AND
|
||||
// MEMORY_V2_CUTOVER is true, export reads from the plugin's namespaces
|
||||
// and import writes through the plugin. Both paths preserve the
|
||||
// SAFE-T1201 redaction shipped in F1084 + F1085.
|
||||
// Issue #1733: the v2 plugin is the only supported backend. Export
|
||||
// reads from the plugin's namespaces; import writes through the plugin.
|
||||
// Both paths preserve the SAFE-T1201 redaction shipped in F1084 + F1085.
|
||||
type AdminMemoriesHandler struct {
|
||||
plugin adminMemoriesPlugin
|
||||
resolver adminMemoriesResolver
|
||||
@@ -69,12 +61,12 @@ func (h *AdminMemoriesHandler) withMemoryV2APIs(plugin adminMemoriesPlugin, reso
|
||||
return h
|
||||
}
|
||||
|
||||
// cutoverActive reports whether the export/import path should route
|
||||
// through the v2 plugin.
|
||||
func (h *AdminMemoriesHandler) cutoverActive() bool {
|
||||
if os.Getenv(envMemoryV2Cutover) != "true" {
|
||||
return false
|
||||
}
|
||||
// memoryV2Wired reports whether the v2 plugin + resolver are attached.
|
||||
// Issue #1733: v2 is now the only path; this replaces the prior
|
||||
// cutoverActive() gate (which also checked MEMORY_V2_CUTOVER=true) —
|
||||
// the env-flag double-check is gone since there's no legacy fallback
|
||||
// to choose against.
|
||||
func (h *AdminMemoriesHandler) memoryV2Wired() bool {
|
||||
return h.plugin != nil && h.resolver != nil
|
||||
}
|
||||
|
||||
@@ -97,48 +89,19 @@ type memoryExportEntry struct {
|
||||
// before returning so that any credentials stored before SAFE-T1201 (#838)
|
||||
// was applied do not leak out via the admin export endpoint.
|
||||
//
|
||||
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
|
||||
// plugin is wired, reads from the plugin instead of agent_memories.
|
||||
// Issue #1733: reads exclusively from the v2 plugin. The legacy direct
|
||||
// agent_memories scan is gone — operators without a configured plugin
|
||||
// get a 503 explaining the required setup.
|
||||
func (h *AdminMemoriesHandler) Export(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if h.cutoverActive() {
|
||||
h.exportViaPlugin(c, ctx)
|
||||
if !h.memoryV2Wired() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT am.id, am.content, am.scope, am.namespace, am.created_at,
|
||||
w.name AS workspace_name
|
||||
FROM agent_memories am
|
||||
JOIN workspaces w ON am.workspace_id = w.id
|
||||
ORDER BY am.created_at
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/export: query error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "export query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memories := make([]memoryExportEntry, 0)
|
||||
for rows.Next() {
|
||||
var m memoryExportEntry
|
||||
if err := rows.Scan(&m.ID, &m.Content, &m.Scope, &m.Namespace, &m.CreatedAt, &m.WorkspaceName); err != nil {
|
||||
log.Printf("admin/memories/export: scan error: %v", err)
|
||||
continue
|
||||
}
|
||||
// F1084 / #1131: redact secrets before returning so pre-SAFE-T1201
|
||||
// memories (stored before redactSecrets was mandatory) don't leak.
|
||||
redacted, _ := redactSecrets(m.WorkspaceName, m.Content)
|
||||
m.Content = redacted
|
||||
memories = append(memories, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("admin/memories/export: rows error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, memories)
|
||||
h.exportViaPlugin(c, ctx)
|
||||
}
|
||||
|
||||
// memoryImportEntry is the JSON shape accepted on import. Matches export format.
|
||||
@@ -160,8 +123,9 @@ type memoryImportEntry struct {
|
||||
// with embedded credentials cannot land unredacted in agent_memories (SAFE-T1201
|
||||
// parity with the commit_memory MCP bridge path).
|
||||
//
|
||||
// CUTOVER (PR-8 / RFC #2728): when MEMORY_V2_CUTOVER=true and the v2
|
||||
// plugin is wired, writes through the plugin instead of agent_memories.
|
||||
// Issue #1733: writes exclusively through the v2 plugin. The legacy
|
||||
// direct agent_memories insert path is gone — operators without a
|
||||
// configured plugin get a 503 explaining the required setup.
|
||||
func (h *AdminMemoriesHandler) Import(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -171,85 +135,13 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.cutoverActive() {
|
||||
h.importViaPlugin(c, ctx, entries)
|
||||
if !h.memoryV2Wired() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "memory plugin is not configured (set MEMORY_PLUGIN_URL)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
errors := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
// 1. Resolve workspace by name
|
||||
var workspaceID string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT id FROM workspaces WHERE name = $1 LIMIT 1`,
|
||||
entry.WorkspaceName,
|
||||
).Scan(&workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/import: workspace %q not found, skipping", entry.WorkspaceName)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// F1085 / #1132: scrub credential patterns before persistence so that
|
||||
// imported memories with secrets don't bypass SAFE-T1201 (#838).
|
||||
// Must run BEFORE the dedup check so the redacted content is what
|
||||
// gets stored — otherwise re-importing the same backup would produce
|
||||
// a duplicate with different (original, unredacted) content.
|
||||
content, _ := redactSecrets(workspaceID, entry.Content)
|
||||
|
||||
// 2. Check for duplicate (same workspace + content + scope) using
|
||||
// the redacted content so that two backups with the same original
|
||||
// secret (same placeholder output) are treated as duplicates.
|
||||
var exists bool
|
||||
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,
|
||||
workspaceID, content, entry.Scope,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/import: duplicate check error for workspace %q: %v", entry.WorkspaceName, err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Insert the memory, preserving original created_at if provided
|
||||
namespace := entry.Namespace
|
||||
if namespace == "" {
|
||||
namespace = "general"
|
||||
}
|
||||
|
||||
if entry.CreatedAt != "" {
|
||||
_, err = db.DB.ExecContext(ctx,
|
||||
`INSERT INTO agent_memories (workspace_id, content, scope, namespace, created_at) VALUES ($1, $2, $3, $4, $5)`,
|
||||
workspaceID, content, entry.Scope, namespace, entry.CreatedAt,
|
||||
)
|
||||
} else {
|
||||
_, err = db.DB.ExecContext(ctx,
|
||||
`INSERT INTO agent_memories (workspace_id, content, scope, namespace) VALUES ($1, $2, $3, $4)`,
|
||||
workspaceID, content, entry.Scope, namespace,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("admin/memories/import: insert error for workspace %q: %v", entry.WorkspaceName, err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"total": len(entries),
|
||||
})
|
||||
h.importViaPlugin(c, ctx, entries)
|
||||
}
|
||||
|
||||
// exportViaPlugin reads memories from the v2 plugin and emits them in
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user