Compare commits

..

2 Commits

Author SHA1 Message Date
hongming 013c8cfe58 review(docs): #1735 patch API spec lines describing awareness_namespace
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 7s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
Check migration collisions / Migration version collision check (pull_request) Successful in 17s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 55s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 27s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m3s
gate-check-v3 / gate-check (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 6s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 7s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
E2E Chat / E2E Chat (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m37s
Harness Replays / Harness Replays (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m9s
CI / Platform (Go) (pull_request) Successful in 5m10s
CI / all-required (pull_request) Successful in 38m28s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
#1737 deletes the awareness_namespace column from `workspaces` and the
provisioner env injection that backed it. Two API-spec lines still
describe the field as if it were live, which would mislead any
external integrator reading the docs against a post-#1737 backend:

- docs/api-reference.md:106 listed `awareness_namespace` as a column
  on the `workspaces` row.
- docs/api-protocol/platform-api.md:93 claimed workspace creation
  "assigns an awareness_namespace on the workspace row" and the
  namespace is "later injected into the provisioned runtime".

Both are factually wrong post-#1737. Patched.

The broader docs sweep (~30 more references across
`docs/architecture/memory.md`, `docs/agent-runtime/*`, READMEs,
superpowers plans, etc.) is tracked in #1753 as a docs-only
follow-up — those are narrative / handbook copy, not contract.

Refs: #1735 (RFC), #1737 (the backend removal PR this rebases on),
#1753 (docs sweep follow-up), review-finding C3 (API doc contradiction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:57:14 -07:00
hongming d20147300b chore(workspace-server): #1735 remove unused Awareness namespace surface
Drop the entire `Awareness namespace` memory-routing plumbing. The
feature was wired through migrations, models, the provisioner, and
several handlers but was never enabled in any environment — the
2026-05-23 sweep against the Railway controlplane (project
molecule-platform, prod env 59227671-…, staging env 639539ec-…) found
zero `AWARENESS_*` env vars set, and the operator-host bootstrap files
(`/etc/molecule-bootstrap/all-credentials.env`, `secrets.env`,
`agent-secrets.env`) likewise had no awareness entries. The provisioner
already only injected the env vars when both URL and namespace were
non-empty (so workspace containers also never received them).

Scope:
- Drop `workspaces.awareness_namespace` column via new forward migration
  `20260523130000_drop_workspaces_awareness_namespace.up.sql` (mirrors
  the recent `drop_runtime_image_pins` shape; paired down migration
  restores migration 010 verbatim).
- Drop `Workspace.AwarenessNamespace` from the model.
- Drop `WorkspaceConfig.AwarenessURL` + `AwarenessNamespace` and the
  conditional env injection in `internal/provisioner/provisioner.go`.
- Drop `workspaceAwarenessNamespace` + `loadAwarenessNamespace`, rename
  the one-line helper to `workspaceMemoryNamespace` (still produces the
  canonical `workspace:<id>` string matching the v2 namespace resolver
  at `internal/memory/namespace/resolver.go:186`).
- `seedInitialMemories` drops its `awarenessNamespace` parameter and
  computes the namespace inline — the parameter was always
  `workspace:<workspaceID>` at every call site, so the value is a pure
  function of the workspace id.
- Update three INSERT call sites (`workspace.go`, `org_import.go`) and
  the org-import root-memory seed in `org.go`.
- Trim `awareness_namespace` from the create-handler response payload.
- Remove ~22 awareness-specific test assertions and SQL-mock arg
  placeholders across `handlers_test.go`, `handlers_additional_test.go`,
  `workspace_test.go`, `workspace_provision_test.go`,
  `workspace_compute_test.go`, `workspace_budget_test.go`,
  `workspace_create_name_integration_test.go`, and
  `provisioner_test.go`.

The `agent_memories.namespace` column (added by migration 017) is
unaffected — seedInitialMemories continues to write `workspace:<id>`
into it, just computed inline now.

Canvas-side cleanup (the `<iframe>` block in `MemoryTab.tsx` reading
`NEXT_PUBLIC_AWARENESS_URL`) is deliberately deferred — it overlaps
with the rewrite landing in #1734 and goes in after that to avoid the
merge conflict.

Verification:
- `go vet ./...` clean.
- `go test -short -count=1 ./...` green (~30 packages).
- Migration round-trip verified on a throwaway `postgres:16-alpine`
  container: up drops the column, down restores it to the same
  `text` type as migration 010.

Refs: #1735 (this issue), #1733 (memory SSOT consolidation), #1734
(canvas Memory tab bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:55:10 -07:00
187 changed files with 3804 additions and 4887 deletions
+3
View File
@@ -50,6 +50,9 @@ 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`.
+4 -4
View File
@@ -547,12 +547,12 @@ def file_or_update(
if dry_run:
print(f"::notice::[dry-run] would file/update drift issue for {branch}")
print("::group::[dry-run] title")
print(f"::group::[dry-run] title")
print(title)
print("::endgroup::")
print("::group::[dry-run] body")
print(f"::endgroup::")
print(f"::group::[dry-run] body")
print(body)
print("::endgroup::")
print(f"::endgroup::")
return
existing = find_open_issue(title)
+14 -46
View File
@@ -183,9 +183,7 @@ def required_contexts_green(
status = latest_statuses.get(context)
state = status_state(status or {})
if state != "success":
if pr_labels and _is_tier_low_pending_ok(
latest_statuses, context, pr_labels
):
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
continue # tier:low soft-fail: accept pending sop-checklist
missing_or_bad.append(f"{context}={state or 'missing'}")
return not missing_or_bad, missing_or_bad
@@ -215,9 +213,7 @@ def choose_next_queued_issue(
if "pull_request" not in issue:
continue
candidates.append(issue)
candidates.sort(
key=lambda issue: (issue.get("created_at") or "", int(issue["number"]))
)
candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"])))
return candidates[0] if candidates else None
@@ -251,8 +247,7 @@ def evaluate_merge_readiness(
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
msg = "main required contexts not green: " + ", ".join(main_bad)
return MergeDecision(False, "pause", msg)
return MergeDecision(False, "pause", "main required contexts not green: " + ", ".join(main_bad))
if not pr_has_current_base:
return MergeDecision(False, "update", "PR head does not contain current main")
@@ -264,8 +259,7 @@ def evaluate_merge_readiness(
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
ok, missing_or_bad = required_contexts_green(latest, required_contexts, pr_labels)
if not ok:
msg = "required contexts not green: " + ", ".join(missing_or_bad)
return MergeDecision(False, "wait", msg)
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
return MergeDecision(True, "merge", "ready")
@@ -300,9 +294,7 @@ def get_combined_status(sha: str) -> dict:
else:
all_statuses = []
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
sys.stderr.write(
f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n"
)
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
all_statuses = []
# Build latest per context: process combined (ascending→reverse=newest
# first), then fill gaps from all_statuses (already newest-first).
@@ -353,17 +345,11 @@ def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None:
print(f"::notice::comment PR #{pr_number}: {body.splitlines()[0][:160]}")
if dry_run:
return
api(
"POST",
f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments",
body={"body": body},
)
api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body})
def update_pull(pr_number: int, *, dry_run: bool) -> None:
print(
f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}"
)
print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}")
if dry_run:
return
api(
@@ -387,12 +373,7 @@ def merge_pull(pr_number: int, *, dry_run: bool) -> None:
if dry_run:
return
try:
api(
"POST",
f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge",
body=payload,
expect_json=False,
)
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
except ApiError as exc:
# Re-raise permission-like errors so process_once can skip this PR.
# 403 = no push access, 404 = repo/pr not found, 405 = not allowed.
@@ -412,8 +393,7 @@ def process_once(*, dry_run: bool = False) -> int:
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
msg = f"queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green"
print(f"::notice::{msg}: {', '.join(main_bad)}")
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
return 0
issue = choose_next_queued_issue(
@@ -431,18 +411,10 @@ def process_once(*, dry_run: bool = False) -> int:
print(f"::notice::PR #{pr_number} is not open; skipping")
return 0
if pr.get("base", {}).get("ref") != WATCH_BRANCH:
post_comment(
pr_number,
f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.",
dry_run=dry_run,
)
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
return 0
if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"):
post_comment(
pr_number,
"merge-queue: skipped; fork PRs are not supported by the serialized queue.",
dry_run=dry_run,
)
post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run)
return 0
head_sha = pr.get("head", {}).get("sha")
@@ -487,17 +459,13 @@ def process_once(*, dry_run: bool = False) -> int:
# maintainers know why, then return 0 so this tick is done.
# The PR stays in the queue; future ticks can retry after the
# permission issue is resolved.
sys.stderr.write(
f"::error::merge permission error for PR #{pr_number}: {exc}\n"
)
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
post_comment(
pr_number,
(
"merge-queue: merge failed with HTTP 405 "
"'User not allowed to merge PR'. "
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
"No available token has Can-merge permission on this repo. "
"Fix: grant Can-merge to a token, or add a "
"maintain/admin collaborator. "
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
"Skipping to next queued PR on next tick."
),
dry_run=dry_run,
@@ -13,6 +13,7 @@ from __future__ import annotations
import argparse
import glob
import re
import sys
from pathlib import Path
from typing import NamedTuple
+1 -1
View File
@@ -283,7 +283,7 @@ def _ensure_labels(repo: str, names: list[str]) -> list[int]:
if status != "ok" or not isinstance(labels, list):
return []
out: list[int] = []
by_name = {lbl["name"]: lbl["id"] for lbl in labels if isinstance(lbl, dict)}
by_name = {l["name"]: l["id"] for l in labels if isinstance(l, dict)}
for n in names:
if n in by_name:
out.append(by_name[n])
@@ -82,7 +82,7 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
+4
View File
@@ -21,6 +21,10 @@ 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)",
]
+48 -46
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016,SC2329
# review-check — evaluate whether a PR satisfies a single team-review gate.
#
# RFC#324 Step 1 of 5 — qa-review + security-review check workflows.
@@ -209,10 +208,10 @@ fi
JQ_FILTER="${JQ_FILTER}
| .user.login"
REVIEW_CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILTER" "$REVIEWS_JSON" | sort -u)
debug "candidate non-author approvers: $(echo "$REVIEW_CANDIDATES" | tr '\n' ' ')"
CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILTER" "$REVIEWS_JSON" | sort -u)
debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')"
if [ -z "$REVIEW_CANDIDATES" ]; then
if [ -z "$CANDIDATES" ]; then
# --- Guardrail (internal#503): explain the most common false
# "no candidates" red. Gitea's review event enum is EXACTLY
# APPROVED/REQUEST_CHANGES/COMMENT/PENDING. A wrong value ("APPROVE",
@@ -237,52 +236,55 @@ if [ -z "$REVIEW_CANDIDATES" ]; then
done
fi
fi
# --- Fallback (internal#348): check issue comments for agent-approval ---
# core-qa-agent and core-security-agent approve via issue comments, NOT
# the reviews API. The reviews API returns zero entries for comment-only
# approvals. This fallback reads PR issue comments and extracts logins that:
# 1. Posted a comment matching the agent-prefix pattern for this gate:
# qa → "[core-qa-agent] APPROVED"
# security → "[core-security-agent] APPROVED"
# OR posted a generic approval keyword (word-anchored, case-insensitive):
# APPROVED / LGTM / ACCEPTED
# 2. Are not the PR author
# 3. The team-membership probe below is the authoritative filter.
AGENT_PATTERN=""
case "$TEAM" in
qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;;
security) AGENT_PATTERN="\\[core-security-agent\\]" ;;
esac
HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments")
debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# JQ expression: select non-author comments that match either the
# agent-prefix pattern (case-insensitive) OR a generic approval keyword.
JQ_APPROVALS='
.[] |
select(.user.login != $author) |
. as $cmt |
if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then
$cmt.user.login
elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then
$cmt.user.login
else
empty
end
'
CANDIDATES=$(jq -r \
--arg author "$PR_AUTHOR" \
--arg agent_pattern "$AGENT_PATTERN" \
"$JQ_APPROVALS" \
"$COMMENTS_JSON" 2>/dev/null | sort -u)
debug "comment-based approval candidates: $(echo "$CANDIDATES" | tr '\n' ' ')"
# --- Fallback/extension (internal#348): check issue comments for agent-approval ---
# core-qa-agent and core-security-agent can approve via issue comments. Always
# include comment candidates, even if the reviews API returned approvals for a
# different team; team membership below is the authoritative filter.
COMMENT_CANDIDATES=""
AGENT_PATTERN=""
case "$TEAM" in
qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;;
security) AGENT_PATTERN="\\[core-security-agent\\]" ;;
esac
HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments")
debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# JQ expression: select non-author comments that match either the
# agent-prefix pattern (case-insensitive) OR a generic approval keyword.
JQ_APPROVALS='
.[] |
select(.user.login != $author) |
. as $cmt |
if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then
$cmt.user.login
elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then
$cmt.user.login
else
empty
end
'
COMMENT_CANDIDATES=$(jq -r \
--arg author "$PR_AUTHOR" \
--arg agent_pattern "$AGENT_PATTERN" \
"$JQ_APPROVALS" \
"$COMMENTS_JSON" 2>/dev/null | sort -u)
debug "comment-based approval candidates: $(echo "$COMMENT_CANDIDATES" | tr '\n' ' ')"
if [ -n "$COMMENT_CANDIDATES" ]; then
echo "::notice::${TEAM}-review: found $(echo "$COMMENT_CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..."
if [ -n "$CANDIDATES" ]; then
echo "::notice::${TEAM}-review: reviews API found no APPROVED reviews; found $(echo "$CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..."
fi
else
debug "could not fetch issue comments (HTTP ${HTTP_CODE})"
fi
else
debug "could not fetch issue comments (HTTP ${HTTP_CODE})"
fi
CANDIDATES=$(printf '%s\n%s\n' "$REVIEW_CANDIDATES" "$COMMENT_CANDIDATES" | sed '/^$/d' | sort -u)
if [ -z "${CANDIDATES:-}" ]; then
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)"
exit 1
+3 -3
View File
@@ -338,7 +338,7 @@ def compute_ack_state(
# Filter out self-acks and unknown slugs.
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
_rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
for (user, slug), kind in latest_directive.items():
@@ -842,7 +842,7 @@ def render_status(
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
labels = pr.get("labels") or []
tier_labels = [lbl.get("name", "") for lbl in labels if (lbl.get("name", "") or "").startswith("tier:")]
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
mode_map = cfg.get("tier_failure_mode") or {}
default_mode = cfg.get("default_mode", "hard")
for tl in tier_labels:
@@ -865,7 +865,7 @@ def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
Governance fix for internal#442 — closes the inconsistency between
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
"""
label_set = {(lbl.get("name") or "") for lbl in (pr.get("labels") or [])}
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
if "tier:high" in label_set:
return True
high_risk_labels = set(cfg.get("high_risk_labels") or [])
@@ -20,7 +20,6 @@ Scenarios:
T15_comments_agent_approval — reviews empty; comments have "[core-qa-agent] APPROVED" → exit 0
T16_comments_generic_approval — reviews empty; comments have "APPROVED" by team member → exit 0
T17_comments_no_approval — reviews empty; comments have no approval keywords → exit 1
T18_review_wrong_team_comment_right_team — review candidate 404s, comment candidate passes
Usage:
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
@@ -81,7 +80,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
# GET /repos/{owner}/{name}/pulls/{pr_number}
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)$", path)
if m:
_owner, _name, pr_num = m.group(1), m.group(2), m.group(3)
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
if sc == "T2_pr_closed":
return self._json(200, {
"number": int(pr_num),
@@ -141,23 +140,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 1},
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 2},
])
if sc == "T18_review_wrong_team_comment_right_team":
return self._json(200, [
{"user": {"login": "core-qa-agent"}, "body": "[core-qa-agent] APPROVED after focused review", "id": 1},
])
# Default scenarios (T1T9, T14): no comments
return self._json(200, [])
# GET /teams/{team_id}/members/{username}
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
if m:
_team_id, login = m.group(1), m.group(2)
team_id, login = m.group(1), m.group(2)
if sc == "T8_team_not_member":
return self._empty(404)
if sc == "T9_team_403":
return self._empty(403)
if sc == "T18_review_wrong_team_comment_right_team" and login == "core-devops":
return self._empty(404)
# T7_team_member: member
return self._empty(204)
@@ -1,31 +0,0 @@
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
@@ -15,6 +15,7 @@ Mirrors the pattern in scripts/ops/test_check_migration_collisions.py
from __future__ import annotations
import importlib.util
import os
import sys
import unittest
from pathlib import Path
@@ -146,10 +146,3 @@ 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)",
]
+3 -16
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env bash
# shellcheck disable=SC2034
# Regression tests for .gitea/scripts/review-check.sh (RFC#324 Step 1).
#
# Covers:
@@ -17,7 +16,6 @@
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
# T13 — missing required env GITEA_TOKEN → exits 1 with error
# T14 — non-default-base PR exits 0 without requiring review
# T18 — wrong-team review candidate does not block right-team comment approval
#
# Hostile-self-review (per feedback_assert_exact_not_substring):
# this test MUST FAIL if the script is absent. Verified by running
@@ -140,7 +138,7 @@ fi
echo
echo "== T13 missing GITEA_TOKEN =="
set +e
T13_OUT=$(PATH="/tmp:$PATH" GITEA_TOKEN='' GITEA_HOST=git.example.com REPO=x/y PR_NUMBER=1 TEAM=qa TEAM_ID=1 bash "$SCRIPT" 2>&1 || true)
T13_OUT=$(PATH="/tmp:$PATH" GITEA_TOKEN= GITEA_HOST=git.example.com REPO=x/y PR_NUMBER=1 TEAM=qa TEAM_ID=1 bash "$SCRIPT" 2>&1 || true)
set -e
assert_contains "T13 exits non-zero when GITEA_TOKEN missing" "GITEA_TOKEN required" "$T13_OUT"
@@ -308,12 +306,12 @@ echo
echo "== T10 CURL_AUTH_FILE =="
# Verify the token-file logic directly: create a temp file with the
# same mktemp pattern, write the header with printf, chmod 600, then assert.
T10_TOKEN="secret-fixture-token-abc123"
T10_TOKEN="secret-test-token-abc123"
T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX")
chmod 600 "$T10_AUTHFILE"
printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE"
assert_file_mode "T10a mktemp authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-fixture-token-abc123"
assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123"
assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token '
rm -f "$T10_AUTHFILE"
@@ -361,17 +359,6 @@ T17_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC"
assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT"
# T18 — a wrong-team PR review candidate must not suppress a right-team
# comment approval. This matches PR #1790, where QA had an APPROVED review
# and security approved via the agent comment convention.
echo
echo "== T18 review candidate wrong team, comment candidate right team =="
T18_OUT=$(run_review_check "T18_review_wrong_team_comment_right_team")
T18_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T18 exit code 0 (comment approval still considered)" "0" "$T18_RC"
assert_contains "T18 comment candidate notice" "comment-based approval" "$T18_OUT"
assert_contains "T18 comment approver accepted" "APPROVED by core-qa-agent" "$T18_OUT"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
@@ -22,6 +22,7 @@ from __future__ import annotations
import os
import sys
import tempfile
import unittest
# Resolve sibling script regardless of where pytest is invoked from.
@@ -14,7 +14,7 @@ def load_reaper():
assert spec.loader is not None
spec.loader.exec_module(mod)
mod.API = "https://git.example.test/api/v1"
mod.GITEA_TOKEN = "fixture-token"
mod.GITEA_TOKEN = "test-token"
mod.API_TIMEOUT_SEC = 1
mod.API_RETRIES = 3
mod.API_RETRY_SLEEP_SEC = 0
+5 -30
View File
@@ -476,11 +476,7 @@ 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. 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.
# or never emit.
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
@@ -488,24 +484,9 @@ jobs:
# sentinel before the `always()` guard can emit a branch-protection status.
#
continue-on-error: false
runs-on: ci-meta
runs-on: ubuntu-latest
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 }}
@@ -513,9 +494,6 @@ 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'
@@ -533,14 +511,11 @@ 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
+2 -3
View File
@@ -143,9 +143,8 @@ jobs:
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"
legacy_token_suffix="test""-token"
if rg -n "$legacy_token_suffix" tests/e2e/test_*staging*.sh; then
echo "::error::staging E2E must use production-safe admin token minting"
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"
+4 -4
View File
@@ -108,13 +108,13 @@ jobs:
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
# Actual E2E: runs on trunk pushes and PRs that touch provisioning-critical
# paths. pr-validate remains as the lightweight workflow-shape check for PRs,
# but it is not a substitute for live staging proof when this workflow or the
# staging harness changes.
# Actual E2E: runs on trunk pushes (main + staging). NOT the PR-fire-only
# path pr-validate above posts success for workflow-only PRs.
e2e-staging-saas:
name: E2E Staging SaaS
runs-on: ubuntu-latest
# Only runs on trunk pushes. PR paths get pr-validate instead.
if: github.event.pull_request.base.ref == ''
# 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
-18
View File
@@ -32,24 +32,6 @@ on:
# iterating all open PRs when PR_NUMBER is empty.
workflow_dispatch:
# Serialize per PR (or per repo for schedule/manual ticks) to prevent
# the fan-out OOM class documented in
# `reference_operator_host_python3_oom_storm_2026_05_18`. `edited`
# events fan out on every PR-body edit; combined with the hourly cron
# and synchronize bursts this workflow can stack runs of the same
# workflow_id on the same PR (each ~4GB anon-RSS) and trip the
# `--memory=4g --memory-swap=8g` per-container cap.
#
# NO `cancel-in-progress` (defaults to false). Per
# `feedback_janitor_supersede_must_group_by_workflow_id`, cancelling
# in-flight runs of any required-check-shaped workflow risks the
# dismiss_stale_approvals + empty-commit-rerun dance (Gitea 1.22.6 has
# no REST rerun). The gate-check is `continue-on-error: true` +
# idempotent (POST/PATCH gate-check comment by context) so sequential
# ticks are strictly safe.
concurrency:
group: gate-check-v3-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
permissions:
# read: contents — for checkout (base ref, not PR head for security)
# read: pull-requests — for reading PR info via API
+18 -14
View File
@@ -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 **four** maintained agent runtimes — Claude Code, Codex, **Hermes**, and OpenClaw — run side by side behind one workspace contract
- 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 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
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.
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.
### 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 through the v2 memory plugin, 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, awareness namespaces, 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 + v2 memory plugin** | Memory sharing follows hierarchy instead of leaking across the whole system; one plugin per tenant, namespace-scoped per workspace |
| **HMA + awareness namespaces** | Memory sharing follows hierarchy instead of leaking across the whole system |
| **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,9 +112,13 @@ 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 |
| **Codex** | Shipping on `main` | OpenAI Codex CLI workflows | 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 |
| **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` |
@@ -129,7 +133,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 | Per-workspace namespaces in the v2 memory plugin reduce blast radius |
| Every agent can become over-privileged | Workspace awareness namespaces reduce blast radius |
| UI memory and runtime memory blur together | Separate surfaces for scoped agent memory, key/value workspace memory, and recall |
### The flywheel
@@ -159,7 +163,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`, `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 |
| **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 |
| **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 |
@@ -168,7 +172,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 the v2 plugin's namespace ACL gives each workspace a durable identity boundary.
Memory can live at `LOCAL`, `TEAM`, or `GLOBAL` scope, and awareness namespaces give 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.
@@ -205,9 +209,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 **4 maintained runtimes** (Claude Code, Codex, Hermes, OpenClaw)
- adapter-driven execution across **8 runtimes** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw)
- Agent Card registration
- **Memory v2 backed by pgvector** — per-tenant plugin sidecar serving HMA namespaces with FTS + semantic recall
- awareness-backed memory integration; **Memory v2 backed by pgvector** for semantic recall
- plugin-mounted shared rules/skills
- hot-reloadable local skills
- coordinator-only delegation path
@@ -241,7 +245,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 Hermes and another prefers Claude Code
- mixed runtime organizations where one team prefers LangGraph 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
@@ -256,9 +260,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)
- 4 adapters: Claude Code / Codex / Hermes / OpenClaw
- 8 adapters: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
- Agent Card + A2A server (typed-SSOT response path, RFC #2967)
- heartbeat + activity + Memory v2 (pgvector semantic recall via per-tenant plugin sidecar)
- heartbeat + activity + awareness-backed memory (Memory v2 pgvector semantic recall)
- skills + plugins + hot reload
SaaS Control Plane (molecule-controlplane, private)
@@ -324,7 +328,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), **four maintained production adapters** (Claude Code, Codex, Hermes, 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), **eight production adapters** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, 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.
+17 -13
View File
@@ -52,7 +52,7 @@ Molecule AI 是目前最强的 AI Agent 组织治理方案之一,用来把 age
它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品:
- 一套组织原生 control plane,管理团队、角色、层级和生命周期
- 一套 runtime abstraction,让 **4**维护中的 agent runtime —— Claude Code、Codex、**Hermes**、OpenClaw —— 共用一套 workspace 契约
- 一套 runtime abstraction,让 **8** agent runtime —— LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、**Hermes**、**Gemini CLI**、OpenClaw —— 共用一套 workspace 契约
- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系(Memory v2 由 pgvector 支撑语义召回)
- 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进
@@ -74,11 +74,11 @@ Molecule AI 填的就是这个空白。
### 3. Runtime 选择不再是死路
Claude Code、Codex、Hermes、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、Hermes、Gemini CLI、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
### 4. Memory 被当成基础设施来做
Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、v2 memory plugin、skill promotion,把这些放在一个完整体系里。
Molecule AI 的 HMA 不是“多存一点上下文”而已。它关注组织边界、durable recall、scope sharing、awareness namespace、skill promotion,把这些放在一个完整体系里。
### 5. 它自带真正的 control plane
@@ -100,7 +100,7 @@ Registry、heartbeat、restart、pause/resume、activity、approval、terminal
| **角色原生 workspace 抽象** | 模型切换、框架切换、团队扩容都不会打碎你的组织结构 |
| **分形式团队扩展** | 一个 specialist 可以平滑升级成一个部门,而不影响上游集成 |
| **异构 runtime 兼容** | 不同团队可以保留偏好的 agent 架构,但共用一套平台规则 |
| **HMA + v2 memory plugin** | Memory 分享沿组织边界走,而不是全局乱穿透;每个 tenant 一个 plugin,按 workspace namespace 隔离 |
| **HMA + awareness namespace** | Memory 分享沿组织边界走,而不是全局乱穿透 |
| **Skill 演化闭环** | 成功工作流可以从 memory 逐步提升成可热加载的 skill |
| **WebSocket-first 运维体验** | Canvas 能即时反映任务状态、结构变更和 A2A 响应 |
| **Global secrets + local override** | 统一管理 provider 凭据,只在需要时做 workspace 级覆写 |
@@ -111,9 +111,13 @@ 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 |
| **Codex** | `main` 已支持 | OpenAI Codex CLI 工作流 | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
| **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry |
| **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 |
| **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` 已合并能力 |
@@ -128,7 +132,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
| 扁平 store 或弱命名空间隔离 | 与层级对齐的 `LOCAL``TEAM``GLOBAL` scope |
| 分享很容易越界 | 分享是显式且结构感知的 |
| Memory 和 procedure 混成一团 | Memory 存 durable factsskills 存 repeatable procedure |
| 任意 agent 容易过权 | v2 memory plugin 的 per-workspace namespace 缩小 blast radius |
| 任意 agent 容易过权 | workspace awareness namespace 缩小 blast radius |
| UI memory 和 runtime memory 混在一起 | scoped agent memory、key/value workspace memory、recall surface 分层清晰 |
### 这套飞轮怎么转
@@ -158,7 +162,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
| 核心机制 | Molecule AI 对应模块 | 为什么重要 |
|---|---|---|
| **跨 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>` |
| **跨 session 的 durable memory** | `workspace/builtin_tools/memory.py``workspace/builtin_tools/awareness_client.py``workspace-server/internal/handlers/memories.go` | 不只是持久化,而且是**按 workspace 隔离**的,可进一步路由到和组织结构绑定的 awareness namespace |
| **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 |
@@ -167,7 +171,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
### 为什么这在 Molecule AI 里更适合团队级系统
1. **学习闭环是 org-aware 的,而不只是 session-aware。**
Memory 可以按 `LOCAL``TEAM``GLOBAL` scope 运作,v2 plugin 的 namespace ACL 让每个 workspace 都有清晰的持久边界。
Memory 可以按 `LOCAL``TEAM``GLOBAL` scope 运作,awareness namespace 让每个 workspace 都有清晰的持久边界。
2. **学习闭环是对运维可见的。**
Promotion events、activity logs、current-task updates、traces、WebSocket fanout 让自我进化进入 control plane,而不是藏在黑盒内部。
@@ -204,9 +208,9 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
### Runtime
- 统一 `workspace/` 镜像;生产环境采用 thin AMIus-east-2
- adapter 驱动执行,覆盖 **4维护中的 runtime**Claude Code、Codex、Hermes、OpenClaw
- adapter 驱动执行,覆盖 **8 个 runtime**Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw
- Agent Card 注册
- **Memory v2 由 pgvector 支撑** —— 每个 tenant 一个 plugin sidecar,承载 HMA namespace、FTS 与语义召回
- awareness-backed memory**Memory v2 由 pgvector 支撑**语义召回
- plugin 挂载共享 rules/skills
- 本地 skills 热加载
- coordinator-only delegation 路径
@@ -255,9 +259,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 集合的镜像)
- 4 个 adapter: Claude Code / Codex / Hermes / OpenClaw
- 8 个 adapter: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
- Agent Card + A2A servertyped-SSOT 响应路径,RFC #2967
- heartbeat + activity + Memory v2pgvector 语义召回per-tenant plugin sidecar
- heartbeat + activity + awareness-backed memoryMemory v2 —— pgvector 语义召回)
- skills + plugins + hot reload
SaaS Control Plane (molecule-controlplane,私有)
@@ -317,7 +321,7 @@ npm run dev
## 当前范围说明
当前 `main` 已经包含核心平台、Canvas v4warm-paper 主题)、Memory v2pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**4维护中的正式 adapter**Claude Code、Codex、Hermes、OpenClaw)、skill lifecycle,以及主要运维面。
当前 `main` 已经包含核心平台、Canvas v4warm-paper 主题)、Memory v2pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**8 个正式 adapter**Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、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。
+6 -12
View File
@@ -49,7 +49,7 @@ export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
};
let authToken = ws.connection?.auth_token;
if (!authToken) {
authToken = await mintWorkspaceToken(ws.id);
authToken = await mintTestToken(ws.id);
}
if (!authToken) {
throw new Error("Workspace created but no auth_token returned");
@@ -202,18 +202,12 @@ export async function cleanupWorkspace(workspaceId: string): Promise<void> {
* Mint a workspace auth token so the canvas can make authenticated API
* calls (WorkspaceAuth middleware).
*/
export async function mintWorkspaceToken(workspaceId: string): Promise<string> {
const headers: Record<string, string> = {};
const adminToken = process.env.E2E_ADMIN_TOKEN ?? process.env.ADMIN_TOKEN;
if (adminToken) {
headers.Authorization = `Bearer ${adminToken}`;
}
const res = await fetch(`${PLATFORM_URL}/admin/workspaces/${workspaceId}/tokens`, {
method: "POST",
headers,
});
export async function mintTestToken(workspaceId: string): Promise<string> {
const res = await fetch(
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
);
if (!res.ok) {
throw new Error(`Failed to mint workspace token: ${res.status}`);
throw new Error(`Failed to mint test token: ${res.status}`);
}
const data = (await res.json()) as { auth_token: string };
return data.auth_token;
-7
View File
@@ -8,7 +8,6 @@
"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",
@@ -1111,12 +1110,6 @@
"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",
-1
View File
@@ -11,7 +11,6 @@
"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",
+1 -56
View File
@@ -24,10 +24,9 @@
* "no memories yet".
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@/lib/api';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useSocketEvent } from '@/hooks/useSocketEvent';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -247,60 +246,6 @@ 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 () => {
+1 -3
View File
@@ -305,9 +305,7 @@ export function SidePanel() {
{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" && selectedNodeId && (
<ContainerConfigTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />
)}
{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} />}
@@ -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 and ScheduleTab tests.
* the pattern established in ApprovalBanner, MemoryTab, and ScheduleTab tests.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
@@ -0,0 +1,93 @@
// @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,17 +31,6 @@ 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,
@@ -527,156 +516,3 @@ 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();
});
});
+1 -1
View File
@@ -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/OnboardingWizard.
// ScheduleTab/MemoryTab/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
+9 -5
View File
@@ -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"], codex ships OpenAI-compatible model ids, etc. Empty list →
// ["anthropic"], gemini-cli ships ["gemini"], etc. Empty list →
// canvas falls back to deriving unique vendor prefixes from
// models[].id (still adapter-driven, just inferred).
providers: string[];
@@ -301,13 +301,16 @@ 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: "codex", label: "Codex", models: [], providers: [] },
{ value: "crewai", label: "CrewAI", models: [], providers: [] },
{ value: "autogen", label: "AutoGen", models: [], providers: [] },
{ value: "deepagents", label: "DeepAgents", 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) {
@@ -496,9 +499,10 @@ 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 (!SUPPORTED_RUNTIME_VALUES.has(v)) continue;
if (!v || v === "langgraph") 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);
@@ -508,7 +512,7 @@ export function ConfigTab({ workspaceId }: Props) {
byRuntime.set(v, { value: v, label: r.name || v, models, providers });
}
}
if (byRuntime.size > 0) setRuntimeOptions(Array.from(byRuntime.values()));
if (byRuntime.size > 1) setRuntimeOptions(Array.from(byRuntime.values()));
})
.catch(() => { /* keep fallback */ });
return () => { cancelled = true; };
+39 -235
View File
@@ -1,206 +1,46 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import type { WorkspaceCompute } from "@/store/socket";
const INSTANCE_TYPES = ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"];
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "langgraph", "kimi", "kimi-cli", "external"];
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
import type { WorkspaceNodeData } from "@/store/canvas";
type Props = {
workspaceId: string;
data: Pick<
WorkspaceNodeData,
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
| "workspaceAccess" | "maxConcurrentTasks" | "compute" | "applyTemplateOnRestart"
| "workspaceAccess" | "maxConcurrentTasks"
>;
};
type FormState = {
runtime: string;
instanceType: string;
rootGB: string;
displayEnabled: boolean;
displayMode: string;
displayProtocol: string;
resolution: string;
};
export function ContainerConfigTab({ workspaceId, data }: Props) {
const initial = useMemo(() => formFromData(data), [
data.runtime,
data.compute?.instance_type,
data.compute?.volume?.root_gb,
data.compute?.display?.mode,
data.compute?.display?.protocol,
data.compute?.display?.width,
data.compute?.display?.height,
]);
const [form, setForm] = useState<FormState>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
setForm(initial);
setError(null);
setSuccess(false);
}, [initial]);
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";
const dirty = JSON.stringify(form) !== JSON.stringify(initial);
const restartLabel = dirty ? "Save & Restart" : "Restart to apply";
const resolutionOptions = RESOLUTIONS.includes(form.resolution)
? RESOLUTIONS
: [form.resolution, ...RESOLUTIONS];
const save = async (restart: boolean) => {
setError(null);
setSuccess(false);
setSaving(true);
try {
let applyTemplateOnRestart = data.applyTemplateOnRestart ?? false;
if (dirty) {
const rootGB = parseInt(form.rootGB, 10);
if (!Number.isFinite(rootGB)) {
setError("Root volume must be a number");
return;
}
const [width, height] = form.resolution.split("x").map((v) => parseInt(v, 10));
const compute: WorkspaceCompute = {
instance_type: form.instanceType,
volume: { root_gb: rootGB },
display: form.displayEnabled
? { mode: form.displayMode, protocol: form.displayProtocol, width, height }
: { mode: "none" },
};
const resp = await api.patch<{ needs_restart?: boolean }>(`/workspaces/${workspaceId}`, {
runtime: form.runtime,
compute,
});
useCanvasStore.getState().updateNodeData(workspaceId, {
runtime: form.runtime,
compute,
needsRestart: resp.needs_restart ?? true,
applyTemplateOnRestart: form.runtime !== initial.runtime,
});
applyTemplateOnRestart = form.runtime !== initial.runtime;
}
if (restart) {
await useCanvasStore.getState().restartWorkspace(workspaceId, {
applyTemplate: applyTemplateOnRestart,
});
}
setSuccess(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
};
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 flex items-center justify-between gap-3">
<div className="mb-3">
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
{data.needsRestart && <span className="text-[11px] text-warm">Restart required</span>}
</div>
<div className="grid grid-cols-1 gap-3 text-[11px]">
<SelectField
id="runtime-image-profile"
label="Runtime image"
value={form.runtime}
options={RUNTIME_OPTIONS}
optionLabel={runtimeDisplayName}
onChange={(runtime) => setForm((s) => ({ ...s, runtime }))}
/>
<SelectField
id="instance-type"
label="Instance type"
value={form.instanceType}
options={INSTANCE_TYPES}
onChange={(instanceType) => setForm((s) => ({ ...s, instanceType }))}
/>
<label className="grid gap-1" htmlFor="root-volume-gb">
<span className="text-ink-mid">Root volume</span>
<div className="flex items-center gap-2">
<input
id="root-volume-gb"
aria-label="Root volume"
type="number"
min={30}
max={500}
value={form.rootGB}
onChange={(e) => setForm((s) => ({ ...s, rootGB: e.target.value }))}
className="min-w-0 flex-1 rounded-md border border-line/60 bg-surface-sunken px-3 py-2 font-mono text-ink outline-none focus:border-accent"
/>
<span className="text-ink-mid">GB</span>
</div>
</label>
<label className="flex items-center justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<span className="text-ink-mid">Display</span>
<input
type="checkbox"
aria-label="Enable display"
checked={form.displayEnabled}
onChange={(e) => setForm((s) => ({
...s,
displayEnabled: e.target.checked,
displayMode: e.target.checked && s.displayMode === "none" ? "desktop-control" : s.displayMode,
displayProtocol: e.target.checked && !s.displayProtocol ? "novnc" : s.displayProtocol,
}))}
className="h-4 w-4 accent-accent"
/>
</label>
{form.displayEnabled && (
<SelectField
id="display-resolution"
label="Resolution"
value={form.resolution}
options={resolutionOptions}
onChange={(resolution) => setForm((s) => ({ ...s, resolution }))}
/>
)}
</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>
<div className="mt-4 flex items-center justify-end gap-2">
{error && <span className="mr-auto text-[11px] text-bad">{error}</span>}
{success && <span className="mr-auto text-[11px] text-good">Saved</span>}
<button
type="button"
disabled={!dirty || saving}
onClick={() => setForm(initial)}
className="rounded-md border border-line/60 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-50"
>
Reset
</button>
<button
type="button"
disabled={!dirty || saving}
onClick={() => save(false)}
className="rounded-md bg-accent px-3 py-2 text-[11px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
disabled={(!dirty && !data.needsRestart) || saving}
onClick={() => save(true)}
className="rounded-md bg-ink px-3 py-2 text-[11px] font-medium text-surface disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Restarting..." : restartLabel}
</button>
<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>
@@ -209,66 +49,13 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
<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="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value="/workspace" />
<ConfigRow label="Delivery mode" value={deliveryMode} />
<ConfigRow label="Mounted path access" value="available" />
</dl>
</section>
</div>
);
}
function formFromData(data: Props["data"]): FormState {
const display = data.compute?.display;
const width = display?.width ?? 1920;
const height = display?.height ?? 1080;
const resolution = `${width}x${height}`;
return {
runtime: data.runtime || "claude-code",
instanceType: data.compute?.instance_type || "t3.large",
rootGB: String(data.compute?.volume?.root_gb || 50),
displayEnabled: !!display?.mode && display.mode !== "none",
displayMode: display?.mode && display.mode !== "none" ? display.mode : "desktop-control",
displayProtocol: display?.protocol || "novnc",
resolution,
};
}
function SelectField({
id,
label,
value,
options,
optionLabel = (v: string) => v,
onChange,
}: {
id: string;
label: string;
value: string;
options: string[];
optionLabel?: (value: string) => string;
onChange: (value: string) => void;
}) {
return (
<label className="grid gap-1" htmlFor={id}>
<span className="text-ink-mid">{label}</span>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-line/60 bg-surface-sunken px-3 py-2 font-mono text-ink outline-none focus:border-accent"
>
{options.map((option) => (
<option key={option} value={option}>
{optionLabel(option)}
</option>
))}
</select>
</label>
);
}
function formatAccess(value: string | null | undefined): string {
if (!value) return "none";
return value.replace(/_/g, "-");
@@ -277,16 +64,33 @@ function formatAccess(value: string | null | undefined): string {
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>
);
}
+12 -72
View File
@@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import type RFB from "@novnc/novnc";
interface DisplayStatus {
available: boolean;
@@ -12,13 +11,13 @@ interface DisplayStatus {
protocol?: string;
width?: number;
height?: number;
viewer_url?: string;
}
interface DisplayControlStatus {
controller: "none" | "user" | "agent";
controlled_by?: string;
expires_at?: string;
session_url?: string;
}
interface Props {
@@ -31,7 +30,6 @@ export function DisplayTab({ workspaceId }: Props) {
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(() => {
@@ -40,7 +38,6 @@ export function DisplayTab({ workspaceId }: Props) {
let cancelled = false;
setStatus(null);
setControl(null);
setSessionUrl(null);
setError(null);
setControlError(null);
setControlBusy(false);
@@ -81,7 +78,6 @@ export function DisplayTab({ workspaceId }: Props) {
});
if (requestGeneration.current !== generation) return;
setControl(next);
setSessionUrl(next.session_url || null);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to take control");
@@ -107,7 +103,6 @@ export function DisplayTab({ workspaceId }: Props) {
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");
@@ -229,19 +224,24 @@ export function DisplayTab({ workspaceId }: Props) {
control={control}
controlBusy={controlBusy}
controlError={controlError}
hasSession={!!sessionUrl}
onAcquire={acquireControl}
onRelease={releaseControl}
/>
</div>
{sessionUrl ? (
<DesktopStream sessionUrl={sessionUrl} />
{status.viewer_url ? (
<iframe
title="Workspace desktop"
src={status.viewer_url}
className="min-h-0 flex-1 border-0 bg-black"
allow="clipboard-read; clipboard-write; fullscreen; pointer-lock"
referrerPolicy="no-referrer"
/>
) : (
<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>
<h3 className="mb-1.5 text-sm font-medium text-ink">Display session is not ready.</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.
This workspace has display configuration, but the desktop session URL is not available yet.
</p>
</div>
</div>
@@ -254,14 +254,12 @@ function DisplayControlBar({
control,
controlBusy,
controlError,
hasSession,
onAcquire,
onRelease,
}: {
control: DisplayControlStatus | null;
controlBusy: boolean;
controlError: string | null;
hasSession: boolean;
onAcquire: () => void;
onRelease: () => void;
}) {
@@ -282,8 +280,7 @@ function DisplayControlBar({
{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)) && (
{control?.controller === "none" && (
<button
type="button"
onClick={onAcquire}
@@ -307,63 +304,6 @@ function DisplayControlBar({
);
}
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";
+471
View File
@@ -0,0 +1,471 @@
"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>
);
}
@@ -1,45 +1,21 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiPatch = vi.fn();
const updateNodeData = vi.fn();
const restartWorkspace = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
patch: (path: string, body: unknown) => apiPatch(path, body),
},
}));
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,
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ restartWorkspace, updateNodeData }),
{ getState: () => ({ restartWorkspace, updateNodeData }) },
),
}));
import { ContainerConfigTab } from "../ContainerConfigTab";
afterEach(() => {
cleanup();
});
beforeEach(() => {
apiPatch.mockReset();
restartWorkspace.mockReset();
updateNodeData.mockReset();
});
describe("ContainerConfigTab", () => {
it("renders persisted compute and status settings", () => {
it("renders read-only runtime and container settings separate from compute shape", () => {
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
@@ -48,249 +24,19 @@ describe("ContainerConfigTab", () => {
maxConcurrentTasks: 3,
workspaceAccess: "read_write",
deliveryMode: "poll",
compute: {
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
display: { mode: "desktop-control", protocol: "novnc", width: 1920, height: 1080 },
},
}}
/>,
);
expect(screen.getByLabelText("Runtime image")).toHaveProperty("value", "claude-code");
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.xlarge");
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "80");
expect(screen.getByLabelText("Enable display")).toHaveProperty("checked", true);
expect(screen.getByLabelText("Resolution")).toHaveProperty("value", "1920x1080");
expect(screen.getByText("Runtime image")).toBeTruthy();
expect(screen.getByText("claude-code")).toBeTruthy();
expect(screen.getByText("Workspace access")).toBeTruthy();
expect(screen.getByText("read-write")).toBeTruthy();
});
it("does not reset dirty form edits on unrelated status rerender", () => {
const { rerender } = render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Root volume"), { target: { value: "120" } });
rerender(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 1,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "120");
});
it("saves runtime and compute changes through workspace PATCH", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Runtime image"), { target: { value: "hermes" } });
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "m6i.xlarge" } });
fireEvent.change(screen.getByLabelText("Root volume"), { target: { value: "100" } });
fireEvent.click(screen.getByLabelText("Enable display"));
fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "2560x1440" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-compute", {
runtime: "hermes",
compute: {
instance_type: "m6i.xlarge",
volume: { root_gb: 100 },
display: { mode: "desktop-control", protocol: "novnc", width: 2560, height: 1440 },
},
});
expect(updateNodeData).toHaveBeenCalledWith("ws-compute", {
runtime: "hermes",
compute: {
instance_type: "m6i.xlarge",
volume: { root_gb: 100 },
display: { mode: "desktop-control", protocol: "novnc", width: 2560, height: 1440 },
},
needsRestart: true,
applyTemplateOnRestart: true,
});
expect(restartWorkspace).not.toHaveBeenCalled();
});
it("preserves existing custom display mode and resolution when saving unrelated compute", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "gpu-desktop-control", protocol: "dcv", width: 1600, height: 1000 },
},
}}
/>,
);
expect(screen.getByLabelText("Resolution")).toHaveProperty("value", "1600x1000");
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "t3.xlarge" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-compute", {
runtime: "claude-code",
compute: {
instance_type: "t3.xlarge",
volume: { root_gb: 50 },
display: { mode: "gpu-desktop-control", protocol: "dcv", width: 1600, height: 1000 },
},
});
});
it("can save changed compute and restart the workspace to apply it", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
restartWorkspace.mockResolvedValueOnce(undefined);
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "t3.xlarge" } });
fireEvent.click(screen.getByRole("button", { name: "Save & Restart" }));
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: false }));
});
it("requests template re-apply when saving a runtime change and restarting", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
restartWorkspace.mockResolvedValueOnce(undefined);
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Runtime image"), { target: { value: "hermes" } });
fireEvent.click(screen.getByRole("button", { name: "Save & Restart" }));
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: true }));
});
it("can restart without re-saving when changes are already pending", async () => {
restartWorkspace.mockResolvedValueOnce(undefined);
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: true,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
applyTemplateOnRestart: true,
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Restart to apply" }));
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: true }));
expect(apiPatch).not.toHaveBeenCalled();
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();
});
});
@@ -2,11 +2,7 @@
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(),
}));
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("@/lib/api", () => ({
api: {
@@ -15,25 +11,6 @@ vi.mock("@/lib/api", () => ({
},
}));
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", () => {
@@ -41,7 +18,6 @@ describe("DisplayTab", () => {
cleanup();
mockGet.mockReset();
mockPost.mockReset();
mockRFBConstructor.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
@@ -95,14 +71,15 @@ describe("DisplayTab", () => {
});
});
it("waits for takeover before opening a ready display stream", async () => {
it("renders the desktop stream when a display session is available", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
protocol: "dcv",
width: 1920,
height: 1080,
viewer_url: "https://display.example.test/session/ws-display",
})
.mockResolvedValueOnce({
controller: "none",
@@ -110,51 +87,12 @@ describe("DisplayTab", () => {
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=");
const frame = screen.getByTitle("Workspace desktop") as HTMLIFrameElement;
expect(frame.src).toBe("https://display.example.test/session/ws-display");
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
it("releases user display control", async () => {
@@ -162,7 +100,8 @@ describe("DisplayTab", () => {
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
protocol: "dcv",
viewer_url: "https://display.example.test/session/ws-display",
})
.mockResolvedValueOnce({
controller: "user",
@@ -0,0 +1,632 @@
// @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");
});
});
@@ -40,7 +40,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -41,7 +41,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -42,7 +42,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -16,7 +16,7 @@ afterEach(cleanup);
// Mock the auth-token env var so AttachmentImage's fetch doesn't
// hit a real network. The fetch is itself mocked below.
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "fixture-token");
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
// Mock fetch so the AttachmentImage path can return a synthetic blob.
// Tests override per-case to simulate success / 404 / network fail.
@@ -44,7 +44,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -43,7 +43,7 @@ vi.mock("../uploads", () => ({
// Mock platformAuthHeaders so fetch gets auth headers
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -12,35 +12,47 @@ 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-custom")).toBe("default-custom");
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
});
it("returns the id unchanged when id has no -default suffix", () => {
expect(resolveRuntime("custom-runtime")).toBe("custom-runtime");
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
});
it("handles custom template ids from community templates", () => {
@@ -13,9 +13,11 @@ import { runtimeDisplayName } from "../runtime-names";
describe("runtimeDisplayName", () => {
it.each([
["claude-code", "Claude Code"],
["codex", "Codex"],
["hermes", "Hermes"],
["langgraph", "LangGraph"],
["deepagents", "DeepAgents"],
["openclaw", "OpenClaw"],
["crewai", "CrewAI"],
["autogen", "AutoGen"],
])("known runtime %q maps to %q", (input, expected) => {
expect(runtimeDisplayName(input)).toBe(expected);
});
@@ -23,6 +25,7 @@ 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",
);
@@ -40,6 +43,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("CODEX")).toBe("CODEX");
expect(runtimeDisplayName("LANGGRAPH")).toBe("LANGGRAPH");
});
});
+4 -2
View File
@@ -63,10 +63,12 @@ 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 -2
View File
@@ -4,9 +4,11 @@
const RUNTIME_NAMES: Record<string, string> = {
"claude-code": "Claude Code",
codex: "Codex",
hermes: "Hermes",
langgraph: "LangGraph",
deepagents: "DeepAgents",
openclaw: "OpenClaw",
crewai: "CrewAI",
autogen: "AutoGen",
kimi: "Kimi",
"kimi-cli": "Kimi CLI",
};
+1 -1
View File
@@ -49,7 +49,7 @@ export interface RuntimeProfile {
}
/** The floor every runtime inherits unless it overrides. Calibrated for
* docker-local fast runtimes (claude-code, codex, openclaw) where cold
* docker-local fast runtimes (claude-code, langgraph, crewai) where cold
* boot is 30-90s. */
export const DEFAULT_RUNTIME_PROFILE: Required<
Pick<RuntimeProfile, "provisionTimeoutMs">
-1
View File
@@ -528,7 +528,6 @@ export function buildNodesAndEdges(
// A2A delivery mode (task #227). Absent on older ws-server builds
// — leave undefined so the chat UI's "?? 'push'" fallback applies.
deliveryMode: ws.delivery_mode,
compute: ws.compute,
},
};
if (hasParent) {
+5 -14
View File
@@ -7,7 +7,7 @@ import {
} from "@xyflow/react";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
import type { WorkspaceCompute, WorkspaceData, WSMessage } from "./socket";
import type { WorkspaceData, WSMessage } from "./socket";
import { handleCanvasEvent } from "./canvas-events";
import { markDeleted, wasRecentlyDeleted } from "./deleteTombstones";
import {
@@ -130,14 +130,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
* builds — that fallthrough is treated as "push" to match
* ws-server's `lookupDeliveryMode` default. */
deliveryMode?: string;
/** Desired EC2/container shape persisted in workspaces.compute. Applied
* at next restart/reprovision, and used to determine Display tab
* availability. */
compute?: WorkspaceCompute;
/** Runtime image changed through Container Config; next restart must
* re-apply the runtime-default template instead of reusing the old
* config volume. UI-only, cleared after restart. */
applyTemplateOnRestart?: boolean;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
@@ -176,7 +168,7 @@ interface CanvasState {
setPanelTab: (tab: PanelTab) => void;
getSelectedNode: () => Node<WorkspaceNodeData> | null;
updateNodeData: (id: string, data: Partial<WorkspaceNodeData>) => void;
restartWorkspace: (id: string, options?: { applyTemplate?: boolean }) => Promise<void>;
restartWorkspace: (id: string) => Promise<void>;
removeNode: (id: string) => void;
/** Remove a node AND every descendant in one atomic update. Mirrors
* the server-side cascade — `DELETE /workspaces/:id?confirm=true`
@@ -829,10 +821,9 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
});
},
restartWorkspace: async (id, options) => {
const body = options?.applyTemplate ? { apply_template: true } : undefined;
await api.post(`/workspaces/${id}/restart`, body);
get().updateNodeData(id, { needsRestart: false, applyTemplateOnRestart: false });
restartWorkspace: async (id) => {
await api.post(`/workspaces/${id}/restart`);
get().updateNodeData(id, { needsRestart: false });
},
removeNode: (id) => {
-14
View File
@@ -354,20 +354,6 @@ export interface WorkspaceData {
* collapsing the spinner the moment the synchronous queued-200 returns
* (task #227 — external/MCP workspaces had no progress UX). */
delivery_mode?: string;
compute?: WorkspaceCompute;
}
export interface WorkspaceCompute {
instance_type?: string;
volume?: {
root_gb?: number;
};
display?: {
mode?: string;
protocol?: string;
width?: number;
height?: number;
};
}
let socket: ReconnectingSocket | null = null;
-9
View File
@@ -1,9 +0,0 @@
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;
}
}
+3 -4
View File
@@ -27,7 +27,7 @@ of the following:
| Endpoint | Impact |
|----------|--------|
| `POST /admin/workspaces/:id/tokens` | Mint a fresh real bearer token for any workspace |
| `GET /admin/workspaces/:id/test-token` | Mint a fresh bearer token for any workspace |
| `DELETE /workspaces/:id` | Delete any workspace and auto-revoke its tokens |
| `PUT /settings/secrets` / `POST /admin/secrets` | Overwrite any global secret (env-poisons every agent on restart) |
| `DELETE /settings/secrets/:key` / `DELETE /admin/secrets/:key` | Delete any global secret; same fan-out restart |
@@ -68,9 +68,8 @@ malicious workspace with a pre-configured `initial_prompt` and elevated secrets.
- **`ValidateAnyToken` removed-workspace JOIN** — tokens belonging to deleted
workspaces are filtered at the DB layer (PR #682 defense-in-depth) so
post-deletion token replay is blocked.
- **Production token mint route** — production and staging automation use
`POST /admin/workspaces/:id/tokens`; development-only shortcuts are not part
of the production contract.
- **`MOLECULE_ENV=production` gate** — hides the `/admin/workspaces/:id/test-token`
endpoint in production deployments unless `MOLECULE_ENABLE_TEST_TOKENS=1`.
## Phase-H remediation plan
+35 -14
View File
@@ -2,14 +2,14 @@
## Overview
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.
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.
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: codex, hermes, openclaw
runtime: claude-code # or: langgraph, openclaw, deepagents, crewai, autogen
runtime_config:
model: sonnet
auth_token_file: .auth-token
@@ -18,7 +18,7 @@ runtime_config:
## How It Works
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.
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.
```
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 adapter
| - invokes the runtime (LangGraph graph, CLI subprocess, etc.)
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,6 +48,10 @@ 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
@@ -67,18 +71,35 @@ 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.
### Codex (`runtime: codex`)
### 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`).
```yaml
runtime: codex
model: openai/gpt-5.3-codex
runtime: crewai
model: openrouter:google/gemini-2.5-flash
```
### Hermes (`runtime: hermes`)
**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.
```yaml
runtime: hermes
model: openai/gpt-4o
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
```
### OpenClaw (`runtime: openclaw`)
@@ -198,9 +219,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).
## Memory Tools
## Workspace Awareness
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.
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.
## Task Status Reporting
+2
View File
@@ -103,6 +103,8 @@ env:
required:
- ANTHROPIC_API_KEY
optional:
- AWARENESS_URL
- AWARENESS_NAMESPACE
- ANTHROPIC_BASE_URL
- OPENAI_BASE_URL
- GSC_CLIENT_ID
+24 -23
View File
@@ -4,11 +4,13 @@ The `workspace/` directory is Molecule AI's unified runtime image. Every provisi
## Runtime Matrix In Current `main`
Current `main` ships four maintained adapters:
Current `main` ships six adapters:
- `langgraph`
- `deepagents`
- `claude-code`
- `codex`
- `hermes`
- `crewai`
- `autogen`
- `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.
@@ -25,7 +27,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
- proxying durable memory tools through the v2 memory plugin
- integrating with awareness-backed memory when configured
- hot-reloading skills while the workspace is running
## Environment Model
@@ -37,6 +39,8 @@ 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=...
@@ -45,7 +49,8 @@ LANGFUSE_SECRET_KEY=...
Important behavior:
- `WORKSPACE_CONFIG_PATH` points at the mounted config directory for that workspace.
- 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.
- `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.
## Startup Sequence
@@ -77,7 +82,8 @@ 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 (route through the platform's v2 memory plugin via the workspace-server proxy) |
| `tools/memory.py` | Agent memory tools |
| `tools/awareness_client.py` | Awareness-backed persistence wrapper |
| `coordinator.py` | Coordinator-only delegation path for team leads |
## Skills, Plugins, And Hot Reload
@@ -97,28 +103,23 @@ 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.
## Memory Integration
## Awareness And Memory Integration
The runtime keeps the agent-facing contract stable:
- `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
- `commit_memory(content, scope)`
- `search_memory(query, scope)`
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.
When awareness is configured:
See [Memory Architecture](../architecture/memory.md) for the full
backend story.
- 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.
## Coordinator Enforcement
+1 -1
View File
@@ -38,7 +38,7 @@ Full contract: `docs/runbooks/admin-auth.md`.
| GET | /settings/secrets | secrets.go — list global secrets (keys only, values masked) |
| PUT/POST | /settings/secrets | secrets.go — set a global secret `{key, value}`; auto-restarts every non-paused/non-removed/non-external workspace that does not shadow the key with a workspace-level override |
| DELETE | /settings/secrets/:key | secrets.go — delete a global secret; same auto-restart fan-out as PUT/POST |
| POST | /admin/workspaces/:id/tokens | admin_workspace_tokens.go — mint a real workspace bearer token; requires `AdminAuth`; plaintext is returned once |
| GET | /admin/workspaces/:id/test-token | admin_test_token.go — mint a fresh bearer token for E2E scripts; returns 404 unless `MOLECULE_ENV != production` or `MOLECULE_ENABLE_TEST_TOKENS=1` |
| GET/POST/DELETE | /admin/secrets[/:key] | secrets.go — legacy aliases for /settings/secrets |
| WS | /workspaces/:id/terminal | terminal.go |
| POST/GET | /workspaces/:id/approvals | approvals.go |
+10 -18
View File
@@ -47,26 +47,18 @@ 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. Memory v2 plugin (`memory_records` / `memory_namespaces`)
### 4. Awareness-backed persistence
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:
When the runtime receives:
- `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`
```bash
AWARENESS_URL=...
AWARENESS_NAMESPACE=workspace:<id>
```
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.
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.
## Access Model
@@ -129,7 +121,7 @@ If you need:
- **org-wide guidance**: use `GLOBAL`
- **simple UI-visible structured state**: use `workspace_memory`
- **recent decision/task recall**: use `session-search`
- **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
- **stronger durable isolation**: enable awareness namespaces
## Related Docs
+30 -10
View File
@@ -426,10 +426,10 @@ submitted → working → completed
| Surface | Storage | Endpoint | Purpose |
|---------|---------|----------|---------|
| **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). |
| **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 → 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: "claude-code" # claude-code | codex | hermes | openclaw
runtime: "langgraph" # langgraph | deepagents | claude-code | crewai | autogen | openclaw
runtime_config: # Runtime-specific settings
command: "claude" # For CLI runtimes
args: []
@@ -565,13 +565,15 @@ compliance:
max_task_duration_seconds: 300
```
### Four Runtime Adapters
### Six 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` |
| **Codex** | OpenAI Codex coding workflows | `workspace-template:codex` |
| **Hermes** | Hermes agent runtime | `workspace-template:hermes` |
| **CrewAI** | Role-based crews, structured task orchestration | `workspace-template:crewai` |
| **AutoGen** | Multi-agent conversations, explicit strategies | `workspace-template:autogen` |
| **OpenClaw** | CLI-native runtime, own session model | `workspace-template:openclaw` |
**Branch-level WIP**: NemoClaw (NVIDIA T4 + Docker socket) on `feat/nemoclaw-t4-docker`.
@@ -738,6 +740,7 @@ 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 |
@@ -746,7 +749,8 @@ requires:
| Server | Purpose |
|--------|---------|
| `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 |
| `molecule` | 20+ platform management tools (workspace CRUD, chat, memory, teams, secrets, files, approvals) |
| `awareness-memory` | Persistent cross-session memory via Awareness SDK |
---
@@ -905,7 +909,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 |
| `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. |
| `AWARENESS_URL` | Optional | Awareness service URL |
### Canvas (Next.js)
@@ -923,6 +927,8 @@ 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 |
@@ -1085,6 +1091,20 @@ 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
+1 -1
View File
@@ -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 `"claude-code"`. This ensures the correct runtime 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 `"langgraph"`. This ensures the correct runtime (e.g. `claude-code`) 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:
+2 -3
View File
@@ -109,9 +109,8 @@ curl -X POST http://localhost:8080/registry/register \
# Response: {"auth_token": "...", ...}
```
Tenant admins can mint a real workspace token through the production-safe admin route:
For development, the test-token endpoint is also available (disabled in production):
```bash
curl -X POST http://localhost:8080/admin/workspaces/<id>/tokens \
-H "Authorization: Bearer <ADMIN_TOKEN>"
curl http://localhost:8080/admin/workspaces/<id>/test-token
# Response: {"auth_token": "...", "workspace_id": "..."}
```
+5 -5
View File
@@ -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 Claude Code, Codex, Hermes, and OpenClaw under one workspace contract and A2A surface.
details: Current main ships adapters for LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw under one workspace contract and A2A surface.
icon: "⚙️"
- title: Hierarchical Memory
details: HMA-style LOCAL, TEAM, and GLOBAL scopes backed by the v2 memory plugin (per-tenant pgvector sidecar with FTS + semantic recall).
details: HMA-style LOCAL, TEAM, and GLOBAL scopes plus workspace-scoped awareness namespaces when awareness is configured.
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 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 |
| **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 |
| **Skills** | Local skill packages, plugin-mounted shared skills/rules, audit/install/publish CLI helpers, hot reload |
## Compatibility Note
`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.
`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.
## Recommended Reading
+2 -2
View File
@@ -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 Claude Code, Codex, Hermes, and OpenClaw.
- **Switch runtime:** use `Config -> Runtime` to move between LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, 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
(Claude Code / Codex / Hermes / OpenClaw)
(LangGraph / Claude Code / CrewAI / AutoGen / etc.)
```
For the full system model, see [Architecture](./architecture/architecture.md).
+41 -6
View File
@@ -1,16 +1,51 @@
# Admin Authentication Runbook
## Required: set `MOLECULE_ENV` in all non-dev environments
## Test-token route: lock in staging and production
The `GET /admin/workspaces/:id/test-token` endpoint mints fresh workspace auth tokens.
It is gated by `TestTokensEnabled()` which returns `true` only when `MOLECULE_ENV != "production"`.
**Effect**: if `MOLECULE_ENV` is unset or set to `development` / `dev` in a staging or production
tenant, the test-token route remains enabled. While the route is protected by `subtle.ConstantTimeCompare`
against `ADMIN_TOKEN` (returns 404 when disabled, not 403), the safest posture is to lock it
out in any environment where it is not intentionally used.
### Required: set MOLECULE_ENV in all non-dev environments
```bash
# In your tenant / EC2 / Railway environment variables:
MOLECULE_ENV=production
```
This matches the production tenant default and disables development-only
shortcuts. Staging and production smoke tests should use the real user/API
workflow: create a workspace, then mint a one-time displayed workspace bearer
with `POST /admin/workspaces/:id/tokens`.
This matches the production tenant default. When `MOLECULE_ENV=production`:
- `TestTokensEnabled()``false`
- `GET /admin/workspaces/:id/test-token` → 404 (route disabled)
### Startup visibility
workspace-server logs the test-token route state at boot:
```
Platform starting on ... (dev-mode-fail-open=...)
```
Additionally, when `TestTokensEnabled()` is `true` (route enabled), the server emits an INFO line
so operators can confirm the setting in logs:
```
[molecule-git-token-helper] NOTE: /admin/workspaces/:id/test-token is ENABLED
(running with MOLECULE_ENV != production)
```
If you do not see this line and the route is still accessible, verify `MOLECULE_ENV` is not set to
`development`, `dev`, or any value that is not exactly `production`.
### Dev environments
In local dev (`MOLECULE_ENV=development` or unset with no `ADMIN_TOKEN`), the test-token route
is intentionally enabled — it is the only way to bootstrap a workspace bearer token without a running
canvas. This is the correct default for developer workstations.
## Admin bearer token (`ADMIN_TOKEN`)
@@ -21,7 +56,7 @@ The platform uses `ADMIN_TOKEN` as the bearer credential for admin-gated endpoin
| `GET/POST/PATCH/DELETE /workspaces` | `Authorization: Bearer <ADMIN_TOKEN>` |
| `GET /admin/liveness` | `Authorization: Bearer <ADMIN_TOKEN>` |
| `POST /org/import` | `Authorization: Bearer <ADMIN_TOKEN>` |
| `POST /admin/workspaces/:id/tokens` | `Authorization: Bearer <ADMIN_TOKEN>`; plaintext token returned once |
| `GET /admin/workspaces/:id/test-token` | `Authorization: Bearer <ADMIN_TOKEN>` (enabled only when `MOLECULE_ENV != "production"`) |
Missing or invalid `ADMIN_TOKEN` → AdminAuth fails open in dev mode (no token set), or
returns 401 in production mode (token set but invalid).
@@ -1,79 +0,0 @@
# 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`.
-104
View File
@@ -1,104 +0,0 @@
# local-e2e — session-continuity canary harness
Self-contained Docker-Compose harness that gates RFC#600-class template
changes (session continuity, file-only messages, multimodal prompts,
cross-session memory) **before** they reach customer canary.
Per CTO standing directive "fully tested + separate CI": this is a
dedicated, *fast* (target <3 min), *small-surface* harness that uses a
Python tenant-CP simulator (not the full `workspace-server` Go service)
to exercise the runtime image end-to-end against canonical canary turns.
See [`feedback_no_single_source_of_truth`] — the harness IS the canonical
session-continuity validator. Per-runtime unit tests still cover their
own guard logic; the harness covers the live conversational behaviour
that those unit tests cannot prove.
See [`feedback_image_promote_is_not_user_live`] — every assertion reads
state back from the *running container*, never from a publish-pipeline
ack.
## What it tests (the 4 canaries)
| # | Scenario | Asserts |
|---|----------|---------|
| 1 | 2-turn name canary | turn 2 reply contains "Hongming" → SessionStore continuity |
| 2 | File-only message (no caption) | NOT "(empty prompt — nothing to do)" + reply references filename or asks for clarification |
| 3 | File + caption ("summarize this") | reply addresses attachment + caption |
| 4 | Cross-session memory recall | new session pulls "blue" via memory tool |
Each scenario re-uses the same A2A wire-shape that the production
`workspace-server` POSTs to runtime `:8000` (canvas-thread-id semantics
via `context_id`).
## Architecture
```
local-e2e/
docker-compose.yml # runtime under test + cp_sim
cp_sim/ # ≈300 LoC Python A2A poster + file uploader
cp_sim.py
Dockerfile
requirements.txt
canary/
conftest.py
test_session_continuity.py # 4 canary scenarios
test_layer_diagnostics.py # SessionStore state probe + key derivation
scripts/
run-canary.sh # one-shot orchestration entrypoint
```
The CP simulator emits the **exact** JSON-RPC `message/send` envelope
that `workspace-server` produces (verified against
`tests/e2e/test_chat_attachments_e2e.sh`). No Go service is in the loop —
this keeps the harness lean per the CTO directive.
## Run locally
```bash
# from molecule-core repo root:
export TEMPLATE_IMAGE=ghcr.io/molecule-ai/workspace-template-hermes:latest
./local-e2e/scripts/run-canary.sh
```
Exit code 0 = all 4 canaries pass. Non-zero = at least one canary failed
and the harness dumped SessionStore state + last 200 log lines from the
runtime container into `./local-e2e/artifacts/`.
## How it integrates into CI
Each template repo's `.gitea/workflows/session-continuity-e2e.yml` calls
`run-canary.sh` with its own freshly-built `TEMPLATE_IMAGE`. The
template repo's Gitea branch-protection lists
`session-continuity-e2e (pull_request)` as a required context.
Rollout order (deliberate — per `feedback_image_promote_is_not_user_live`
we bake before we cascade):
1. `molecule-ai-workspace-template-hermes` — highest-traffic + most
recent RFC#600-class fixes — REQUIRED gate
2. Bake for 5 business days
3. Cascade to claude-code, langgraph, autogen, openclaw, smolagents,
google-adk (one PR per template — see `scripts/onboard-template.sh`)
## Future extensions (out of scope for the initial PR)
- Multi-session memory consistency (3+ sessions deep)
- Tool-use canary (workspace seeded with skills/, agent must invoke)
- Streaming-cancellation canary (mid-stream client disconnect)
- Cross-runtime A2A peer call (currently covered by `e2e-peer-visibility`)
## Why a thin Python simulator and not the real `workspace-server`?
`workspace-server` is a 60+ MB Go binary that requires Postgres, Redis,
admin-token wiring, registry plumbing, and a 30+ second cold-boot. None
of that touches session-continuity behaviour, which is fully owned by
the runtime container's `a2a_executor.py`. Per CTO directive "separate
CI as possible" + the <3 min target, we excise the platform-tenant Go
service from the loop and emit identical wire-shape envelopes from a
single Python file.
If the simulator diverges from `workspace-server` wire shape, the gate
goes red — fix the simulator to match production. The wire shape is
asserted in `tests/e2e/test_chat_attachments_e2e.sh` and the runtime's
`workspace/a2a_executor.py:_core_execute`.
-19
View File
@@ -1,19 +0,0 @@
# Python tenant-CP simulator + canary test driver.
# Single image — pytest + httpx + the canary tests baked in.
FROM python:3.11-slim@sha256:e78299e55776ca065dcb769f80161f48465ad352014240eb5fe4712e22505e9b
WORKDIR /harness
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Test files are bind-mounted by docker-compose at run time so a `pytest -x`
# rerun loop doesn't require a rebuild. The COPY here is for the
# self-contained image used by Gitea Actions (where bind mounts are awkward).
COPY cp_sim.py /harness/cp_sim.py
COPY canary /harness/canary
ENV PYTHONUNBUFFERED=1
# Default: run the 4 canaries with verbose output + JUnit XML for CI.
CMD ["pytest", "-v", "--tb=short", "--junitxml=/harness/artifacts/junit.xml", "canary/"]
View File
-31
View File
@@ -1,31 +0,0 @@
"""Shared pytest fixtures for the canary suite."""
from __future__ import annotations
import os
import sys
import uuid
# cp_sim.py lives one dir up — make it importable without packaging.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest # noqa: E402
from cp_sim import CPSim, CPSimConfig # noqa: E402
@pytest.fixture
def sim() -> CPSim:
"""Fresh CPSim per test — cheap, isolates connection state."""
return CPSim(
cfg=CPSimConfig(
runtime_url=os.environ.get("RUNTIME_URL", "http://localhost:18000"),
)
)
@pytest.fixture
def context_id() -> str:
"""A unique canvas-thread-id per test — guarantees SessionStore isolation
between scenarios so a failing canary doesn't poison the next one."""
return f"canary-ctx-{uuid.uuid4().hex[:12]}"
@@ -1,80 +0,0 @@
"""Layer-isolation diagnostics — runs alongside the 4 canaries.
These probes are not strict pass/fail gates by themselves; they exist so
when a canary fails, the artifacts include enough state to tell whether
the regression is in the wire-shape layer, the SessionStore layer, or
the memory layer. Each test always passes (returns early) when the
underlying surface is unavailable on the runtime under test different
templates expose different debug endpoints.
Cross-refs:
- feedback_verify_actual_endstate_not_ack_follow_sop we read state
back, not the side-effect ack.
- feedback_image_promote_is_not_user_live the verification is at
the running-container layer.
"""
from __future__ import annotations
import os
import uuid
import httpx
from cp_sim import CPSim
def test_diag_agent_card_advertises_a2a(sim: CPSim) -> None:
"""The runtime's /agent-card must advertise A2A capabilities.
If this fails, the canaries' transport assumption (POST /a2a) is
already broken diagnose the runtime image, not the canary.
"""
url = f"{sim.cfg.runtime_url}/agent-card"
r = httpx.get(url, timeout=10.0)
assert r.status_code == 200, (
f"/agent-card returned {r.status_code}: {r.text[:300]!r}"
)
body = r.json()
# AgentCard spec: capabilities object must exist, even if empty.
assert isinstance(body, dict), f"/agent-card body not an object: {body!r}"
# We don't require any specific capability flag — different templates
# advertise different sets. The point of this diag is "is the card
# there at all", which signals the runtime booted past entrypoint.
def test_diag_context_id_required_for_continuity(sim: CPSim) -> None:
"""Same context_id in two turns must not crash the runtime.
Pure smoke probe proves the executor accepts a continuation
message without 5xx-ing. The substantive assertion is canary 1; this
one just guarantees the path is reachable.
"""
ctx = f"diag-{uuid.uuid4().hex[:8]}"
r1 = sim.send_text("ping", context_id=ctx)
r2 = sim.send_text("ping again", context_id=ctx, task_id=r1.get("result", {}).get("id"))
# Both replies must parse — non-empty envelope, no JSON-RPC error.
for label, env in (("turn1", r1), ("turn2", r2)):
assert "error" not in env, f"{label} returned JSON-RPC error: {env['error']}"
def test_diag_memory_root_writable_in_canary_mode(sim: CPSim) -> None:
"""When MOLECULE_CANARY_MODE=1, the memory root must accept writes.
Probes via the recall_memory MCP tool if /mcp is not exposed,
returns early (skip-style; we still pass because some templates
proxy MCP elsewhere).
"""
# We can't write directly here — only confirm the read path doesn't
# 500 on a missing key. A real write happens in canary 4.
key = f"canary-probe-{uuid.uuid4().hex[:8]}"
try:
val = sim.probe_memory(key)
except Exception as e:
# /mcp may not be exposed on this template — canary 4 will
# surface the real defect if memory is actually broken.
if os.environ.get("CANARY_STRICT_MCP") == "1":
raise
return
# Unknown key → None is fine. The point is the call didn't crash.
assert val is None or isinstance(val, str)
@@ -1,204 +0,0 @@
"""The 4 canonical session-continuity canaries (task #342, RFC#600 class).
These tests speak A2A directly to the runtime under test. They are the
authoritative gate that the runtime preserves conversation continuity,
handles file-only messages without dropping to the empty-prompt error,
addresses multimodal prompts, and persists memory across sessions.
Wire-shape source of truth: see ../cp_sim.py docstring.
"""
from __future__ import annotations
import re
import uuid
from cp_sim import CPSim
# ---------- canary 1: 2-turn name continuity -------------------------------
def test_canary_1_two_turn_name_continuity(sim: CPSim, context_id: str) -> None:
"""SessionStore continuity — turn 2 must recall the name from turn 1.
Empirically tests:
- ``a2a_executor._core_execute`` injects prior-turn history via
``_extract_history(context)`` (workspace/a2a_executor.py:313).
- The runtime's session store is keyed on ``context_id`` (canvas
thread id) NOT ``task_id`` task_id is per-turn, context_id is
per-conversation. Regressions to that key derivation were the
root cause of the 2026-05 multi-turn-amnesia incidents
(#a60623344 diagnosis).
"""
# Turn 1 — establish the fact.
r1 = sim.send_text(
"Hi, my name is Hongming.",
context_id=context_id,
)
reply1 = sim.extract_text_parts(r1)
assert reply1, f"Turn 1 produced empty reply. envelope={r1!r}"
# Turn 2 — ask back. Same context_id → same SessionStore key.
r2 = sim.send_text(
"What's my name?",
context_id=context_id,
)
reply2 = sim.extract_text_parts(r2)
assert reply2, f"Turn 2 produced empty reply. envelope={r2!r}"
# Substring match, case-insensitive — agents may reply
# "Your name is Hongming." or "It's Hongming!" or similar.
assert re.search(r"\bhongming\b", reply2, flags=re.IGNORECASE), (
f"Turn 2 reply does not contain 'Hongming' — SessionStore "
f"continuity regression suspected. context_id={context_id} "
f"turn1_reply={reply1[:200]!r} turn2_reply={reply2[:400]!r}"
)
# ---------- canary 2: file-only message (no caption) -----------------------
_DROPPED_TURN_MARKERS = (
"(empty prompt — nothing to do)",
"empty prompt",
"message contained no text content",
"no text content",
)
def test_canary_2_file_only_message(sim: CPSim, context_id: str) -> None:
"""File-attached A2A message with NO text part must not be dropped.
Root cause this guards against: a long-standing executor bug where
``extract_message_text`` returned "" for file-only messages and the
executor short-circuited with the "Error: message contained no text
content." reply, even though the attached file was the entire point
of the turn.
Hard assertions:
- Reply is non-empty AND not the dropped-turn marker.
- Reply references the file by name OR asks an actionable
clarifying question (NOT a flat error).
"""
file_name = f"canary-{uuid.uuid4().hex[:8]}.txt"
file_body = b"Project status: nominal. Lighthouse score 98."
r = sim.send_with_file(
context_id=context_id,
text=None, # ← THE CANARY: no caption.
file_name=file_name,
file_bytes=file_body,
mime_type="text/plain",
)
reply = sim.extract_text_parts(r)
assert reply, f"File-only message produced empty reply. envelope={r!r}"
low = reply.lower()
for marker in _DROPPED_TURN_MARKERS:
assert marker.lower() not in low, (
f"File-only message was dropped — reply contains "
f"{marker!r}. Full reply: {reply[:500]!r}"
)
# Soft assertion: reply must engage with the file (reference its
# name) OR ask an actionable clarification. We require ONE of those —
# a generic "Hello! How can I help?" reply is also a drop.
name_referenced = file_name.lower() in low or "file" in low or "attach" in low
asks_clarification = (
"what" in low or "would you like" in low or "?" in reply
)
assert name_referenced or asks_clarification, (
f"File-only reply neither references the file nor asks a "
f"clarifying question. Reply: {reply[:500]!r}"
)
# ---------- canary 3: file + prompt (multimodal) ---------------------------
def test_canary_3_file_with_prompt(sim: CPSim, context_id: str) -> None:
"""File-attached A2A message WITH a caption — multimodal happy path.
Lower bar than canary 2: assert the agent acknowledges the file was
received and tries to address the caption. We deliberately don't
require a perfect summary because canary mode replies are canned
the goal is to prove the executor's multimodal code path doesn't
drop EITHER the file OR the caption.
"""
file_name = f"canary-doc-{uuid.uuid4().hex[:8]}.txt"
file_body = (
b"Quarterly review. Revenue up 14%. Churn down 3%. "
b"Team headcount steady. Action: ship RFC#600 by end of week."
)
r = sim.send_with_file(
context_id=context_id,
text="summarize this",
file_name=file_name,
file_bytes=file_body,
mime_type="text/plain",
)
reply = sim.extract_text_parts(r)
assert reply, f"File+prompt produced empty reply. envelope={r!r}"
low = reply.lower()
for marker in _DROPPED_TURN_MARKERS:
assert marker.lower() not in low, (
f"File+prompt was dropped — reply contains {marker!r}. "
f"Full reply: {reply[:500]!r}"
)
# At minimum: the reply must mention file/attach/summary semantics,
# demonstrating the executor accepted both parts.
engaged = any(
kw in low for kw in ("file", "attach", "summary", "summarize", "content", file_name.lower())
)
assert engaged, (
f"Multimodal reply doesn't engage with attached file or caption. "
f"Reply: {reply[:500]!r}"
)
# ---------- canary 4: cross-session memory recall --------------------------
def test_canary_4_cross_session_memory_recall(sim: CPSim) -> None:
"""Memory persists across distinct context_ids → memory layer (NOT
SessionStore) is the storage.
Two distinct context_ids in this test SessionStore CANNOT bridge
them. The bridge is the runtime's persistent memory (MOLECULE_MEMORY_ROOT
in canary mode). If the recall returns "blue" in session 2, the
memory layer is wired correctly.
Note: we ask the agent to commit the memory explicitly in session 1
so that the canary doesn't depend on memory auto-extraction
heuristics (which vary by runtime). The commit goes through the
same MCP tool the canvas would invoke.
"""
ctx_a = f"canary-ctx-{uuid.uuid4().hex[:12]}"
ctx_b = f"canary-ctx-{uuid.uuid4().hex[:12]}"
# Session 1 — commit a fact via the memory tool. Use the explicit
# "remember" verb so canary-mode agents (which short-circuit to a
# deterministic tool-call) reliably invoke `commit_memory`.
r1 = sim.send_text(
"Please use the memory tool to remember: my favorite color is blue.",
context_id=ctx_a,
)
reply1 = sim.extract_text_parts(r1)
assert reply1, f"Session 1 produced empty reply. envelope={r1!r}"
# Session 2 — different context_id. Same workspace, same memory.
r2 = sim.send_text(
"Use the memory tool to recall my favorite color, then tell me what it is.",
context_id=ctx_b,
)
reply2 = sim.extract_text_parts(r2)
assert reply2, f"Session 2 produced empty reply. envelope={r2!r}"
assert re.search(r"\bblue\b", reply2, flags=re.IGNORECASE), (
f"Session 2 reply does not contain 'blue' — cross-session memory "
f"recall regression suspected. ctx_a={ctx_a} ctx_b={ctx_b} "
f"session1_reply={reply1[:200]!r} session2_reply={reply2[:400]!r}"
)
-214
View File
@@ -1,214 +0,0 @@
"""Tenant control-plane simulator.
Emits the byte-identical JSON-RPC `message/send` wire shape that the
production `workspace-server` POSTs to the runtime's :8000 — see
``workspace-server/internal/handlers/a2a.go`` and the canonical sample
in ``tests/e2e/test_chat_attachments_e2e.sh``.
This file is purposefully small (~250 LoC). It is NOT a re-implementation
of `workspace-server`; it is just the minimum surface required to drive
the 4 session-continuity canaries.
If the runtime asserts on a header / envelope field that the production
platform sets but this simulator omits, FIX THE SIMULATOR never weaken
the runtime to accept divergent wire shapes. The simulator is the
canonical contract emitter for canary purposes
(``feedback_no_single_source_of_truth``).
"""
from __future__ import annotations
import base64
import json
import os
import uuid
from dataclasses import dataclass
from typing import Any
import httpx
@dataclass
class CPSimConfig:
runtime_url: str
"""Base URL of the runtime under test (e.g. http://runtime:8000)."""
request_timeout_s: float = 60.0
"""Per-A2A-call timeout. Generous — canary mode replies are fast,
but a real Provider-backed runtime under cold cache can take 30+s."""
class CPSim:
"""Thin client matching workspace-server's wire shape."""
def __init__(self, cfg: CPSimConfig | None = None) -> None:
self.cfg = cfg or CPSimConfig(
runtime_url=os.environ.get("RUNTIME_URL", "http://localhost:18000"),
)
self._client = httpx.Client(timeout=self.cfg.request_timeout_s)
# ------------------------------------------------------------------ A2A
def send_text(
self,
text: str,
*,
context_id: str,
task_id: str | None = None,
) -> dict[str, Any]:
"""POST a text-only A2A message. Returns the JSON-RPC envelope."""
msg_id = f"canary-{uuid.uuid4().hex[:12]}"
payload = {
"jsonrpc": "2.0",
"id": msg_id,
"method": "message/send",
"params": {
"message": {
"role": "user",
"messageId": msg_id,
"kind": "message",
"contextId": context_id,
"taskId": task_id,
"parts": [{"kind": "text", "text": text}],
},
"configuration": {
"acceptedOutputModes": ["text/plain"],
"blocking": True,
},
},
}
return self._post(payload)
def send_with_file(
self,
*,
context_id: str,
text: str | None,
file_name: str,
file_bytes: bytes,
mime_type: str = "text/plain",
task_id: str | None = None,
) -> dict[str, Any]:
"""POST an A2A message with an inline file part.
Uses the inline `bytes` form of A2A file parts (RFC#600 — the
no-URI variant added precisely so canary tests don't need a
`/chat/uploads` round-trip). Each runtime's executor calls
``extract_attached_files`` which handles both forms verified
in ``workspace/executor_helpers.py:903``.
"""
msg_id = f"canary-{uuid.uuid4().hex[:12]}"
parts: list[dict[str, Any]] = []
if text:
parts.append({"kind": "text", "text": text})
parts.append(
{
"kind": "file",
"file": {
"name": file_name,
"mimeType": mime_type,
"bytes": base64.b64encode(file_bytes).decode("ascii"),
},
}
)
payload = {
"jsonrpc": "2.0",
"id": msg_id,
"method": "message/send",
"params": {
"message": {
"role": "user",
"messageId": msg_id,
"kind": "message",
"contextId": context_id,
"taskId": task_id,
"parts": parts,
},
"configuration": {
"acceptedOutputModes": ["text/plain"],
"blocking": True,
},
},
}
return self._post(payload)
# ------------------------------------------------------------ helpers
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
url = f"{self.cfg.runtime_url}/a2a"
try:
r = self._client.post(url, json=payload)
except httpx.HTTPError as e:
raise CPSimError(f"A2A POST failed: {e}") from e
if r.status_code != 200:
raise CPSimError(
f"A2A non-200: status={r.status_code} body={r.text[:500]}"
)
try:
return r.json()
except json.JSONDecodeError as e:
raise CPSimError(f"A2A body not JSON: {r.text[:500]}") from e
@staticmethod
def extract_text_parts(envelope: dict[str, Any]) -> str:
"""Return concatenated text from all text parts of a reply.
Handles both top-level `result.parts` (the canonical shape) and
`result.artifacts[*].parts` (which some runtimes emit when the
reply was streamed as artifact chunks). Matches the extractor in
``tests/e2e/test_chat_attachments_e2e.sh``.
"""
result = envelope.get("result") or {}
chunks: list[str] = []
for p in result.get("parts", []) or []:
if p.get("kind") == "text":
chunks.append(p.get("text", ""))
for art in result.get("artifacts", []) or []:
for p in art.get("parts", []) or []:
if p.get("kind") == "text":
chunks.append(p.get("text", ""))
# Some runtimes return a status.message instead of/in addition to parts.
status = result.get("status") or {}
status_msg = status.get("message") or {}
for p in status_msg.get("parts", []) or []:
if p.get("kind") == "text":
chunks.append(p.get("text", ""))
return "\n".join(chunks).strip()
# ----------------------------------------------------- memory probe
def probe_memory(self, key: str) -> str | None:
"""Read a memory value via the runtime's MCP memory tool.
Uses the same MCP transport the canvas uses
(``POST /workspaces/:id/mcp``-shaped JSON-RPC over /mcp). Returns
the recalled string or None if the key is missing.
"""
payload = {
"jsonrpc": "2.0",
"id": f"canary-mem-{uuid.uuid4().hex[:8]}",
"method": "tools/call",
"params": {"name": "recall_memory", "arguments": {"key": key}},
}
try:
r = self._client.post(f"{self.cfg.runtime_url}/mcp", json=payload)
except httpx.HTTPError as e:
raise CPSimError(f"MCP POST failed: {e}") from e
if r.status_code != 200:
return None
body = r.json()
result = body.get("result") or {}
# MCP responses wrap the tool output in result.content[*].text per
# the JSON-RPC tools/call contract.
for c in result.get("content", []) or []:
if c.get("type") == "text":
return c.get("text")
return None
class CPSimError(RuntimeError):
"""Raised on transport / envelope failures (NOT canary assertion failures).
Distinct from AssertionError so pytest reports them as ERROR not
FAILED a transport-layer fault should be debugged differently from
a real session-continuity regression.
"""
-5
View File
@@ -1,5 +0,0 @@
# Pinned (not floating) so the harness is reproducible across CI runs.
# These versions match what tests/e2e/_lib.sh and tests/e2e/conftest.py use.
httpx==0.27.2
pytest==8.3.3
pytest-asyncio==0.24.0
-58
View File
@@ -1,58 +0,0 @@
# local-e2e/docker-compose.yml — minimal harness stack.
#
# Two services:
# runtime — the template image under test (TEMPLATE_IMAGE env var).
# Exposes :8000 for A2A traffic. The simulator POSTs to it.
# cp_sim — thin Python tenant-CP simulator. Drives the canary turns.
#
# Deliberately NO postgres, NO redis, NO platform Go service. SessionStore
# continuity is a runtime-internal concern (a2a_executor + executor_helpers);
# we test it without dragging the platform-tenant Go binary into the loop.
# See README.md "Why a thin Python simulator" for rationale.
services:
runtime:
image: ${TEMPLATE_IMAGE:?TEMPLATE_IMAGE env required, e.g. ghcr.io/molecule-ai/workspace-template-hermes:latest}
# The runtime entrypoint (workspace/entrypoint.sh) refuses to start when
# any operator-scope env var is present. We deliberately set no creds —
# the canary doesn't invoke a real LLM provider (see TEST_NO_PROVIDER below).
environment:
# Disable provider calls during canary — the runtime returns canned
# echo-style replies so the harness can assert continuity / file-handling
# behaviour without burning provider quota. The template image must
# honour MOLECULE_CANARY_MODE=1 (added in molecule-ai-workspace-runtime
# PR #46 — see molecule_runtime/a2a_executor.py canary short-circuit).
MOLECULE_CANARY_MODE: "1"
# Anonymous workspace identity so RBAC paths exercise the same code
# they would in tenant production.
WORKSPACE_ID: "canary-${CANARY_RUN_ID:-local}"
# Memory tool requires a writable scope; point at /tmp inside the
# container so cross-session canary (#4) works without bind mounts.
MOLECULE_MEMORY_ROOT: "/tmp/canary-memory"
# The provisioner's forbidden-env guard exits non-zero when any
# operator-scope literal is present; the canary intentionally sets
# zero of them. Leave guard ON (do NOT set MOLECULE_TENANT_GUARD_DISABLE)
# so we exercise the prod entrypoint code path verbatim.
ports:
- "${RUNTIME_PORT:-18000}:8000"
healthcheck:
# /agent-card is the universal A2A discovery endpoint — every template
# exposes it. /health varies per template.
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://localhost:8000/agent-card || exit 1"]
interval: 3s
timeout: 3s
retries: 20
start_period: 30s
cp_sim:
build:
context: ./cp_sim
depends_on:
runtime:
condition: service_healthy
environment:
RUNTIME_URL: "http://runtime:8000"
CANARY_RUN_ID: "${CANARY_RUN_ID:-local}"
# cp_sim doesn't expose a port — it's a one-shot driver invoked by
# run-canary.sh via `docker compose run cp_sim pytest ...`.
profiles: ["driver"]
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
# onboard-template.sh — gitops helper to wire local-e2e into a new template.
#
# Drops .gitea/workflows/session-continuity-e2e.yml into the target template
# repo (a thin shim that clones molecule-core's local-e2e harness, then runs
# run-canary.sh against the locally-built template image). Opens a PR.
#
# Usage:
# ./local-e2e/scripts/onboard-template.sh molecule-ai-workspace-template-claude-code
#
# Per task #342 sequencing: do NOT run this for every template at once.
# Bake the gate on hermes for ≥5 business days first; expand only after
# the canary is empirically stable.
#
# Cross-refs:
# feedback_no_single_source_of_truth — the workflow content is identical
# across templates; this helper guarantees it.
# feedback_image_promote_is_not_user_live — we wire the gate at the
# CI layer; flipping it to REQUIRED in branch_protection is a
# separate step (see README.md).
set -euo pipefail
REPO="${1:?usage: onboard-template.sh <template-repo-name>}"
HARNESS_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
# Sanity: ensure the template-side workflow file exists in this repo.
TEMPLATE_WORKFLOW="$HARNESS_ROOT/templates/session-continuity-e2e.yml"
[ -f "$TEMPLATE_WORKFLOW" ] || {
echo "ERROR: $TEMPLATE_WORKFLOW not found in this harness checkout"
exit 1
}
WORK_DIR=$(mktemp -d -t e2e-onboard-XXXXXX)
trap 'rm -rf "$WORK_DIR"' EXIT
cd "$WORK_DIR"
# Use mol_clone — preserves the persona credential model.
# shellcheck disable=SC1090
source "$HOME/.molecule-ai/ops.sh"
mol_clone "$REPO"
cd "$REPO"
git checkout -b "task342/session-continuity-e2e-gate"
mkdir -p .gitea/workflows
cp "$TEMPLATE_WORKFLOW" .gitea/workflows/session-continuity-e2e.yml
git add .gitea/workflows/session-continuity-e2e.yml
git commit -m "ci: add local-e2e session-continuity canary gate (task #342)
Wires this template into the cross-template session-continuity harness
in molecule-ai/molecule-core/local-e2e/. The gate boots THIS repo's
locally-built image, drives 4 canonical canaries (2-turn name continuity,
file-only message, file+prompt, cross-session memory recall), and fails
PRs that regress any of them.
Per CTO directive: required-context flip in branch_protection is a
SEPARATE step after 5 business days of bake."
# Push branch; do not auto-open PR — leave that to the operator so the
# review-relay routing follows the same rules as a normal change.
git push -u origin "task342/session-continuity-e2e-gate"
echo
echo "DONE. Branch pushed to $REPO. Open PR manually:"
echo " https://git.moleculesai.app/molecule-ai/$REPO/compare/main...task342/session-continuity-e2e-gate"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# run-canary.sh — one-shot orchestration for the local-e2e session-continuity
# canary harness. Used by both interactive local runs and the per-template
# .gitea/workflows/session-continuity-e2e.yml.
#
# Usage:
# TEMPLATE_IMAGE=ghcr.io/molecule-ai/workspace-template-hermes:latest \
# ./local-e2e/scripts/run-canary.sh
#
# Optional env:
# CANARY_RUN_ID — disambiguator for parallel CI runs (default: random)
# RUNTIME_PORT — host port for runtime :8000 (default: 18000)
# KEEP_RUNNING — set =1 to leave containers up for post-mortem
#
# Exit codes:
# 0 — all 4 canaries passed
# 1 — at least one canary failed (artifacts/ has the dump)
# 2 — harness infrastructure failure (image pull / compose / etc.)
#
# Cross-refs:
# feedback_image_promote_is_not_user_live — we verify at the running
# container layer, NOT at the pipeline-green layer.
# feedback_verify_actual_endstate_not_ack_follow_sop — every assert
# reads state back; no side-effect-ack claims success.
set -euo pipefail
: "${TEMPLATE_IMAGE:?TEMPLATE_IMAGE env required (the runtime image under test)}"
# ----------------------------------------------------------------- paths
HARNESS_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
ARTIFACTS_DIR="$HARNESS_ROOT/artifacts"
mkdir -p "$ARTIFACTS_DIR"
export CANARY_RUN_ID="${CANARY_RUN_ID:-$(uuidgen 2>/dev/null | tr A-Z a-z | tr -d - | cut -c1-12 || date +%s)}"
export RUNTIME_PORT="${RUNTIME_PORT:-18000}"
export TEMPLATE_IMAGE
COMPOSE_PROJECT="canary-${CANARY_RUN_ID}"
COMPOSE_FILE="$HARNESS_ROOT/docker-compose.yml"
log() { printf "\n=== [%s] %s ===\n" "$(date +%H:%M:%S)" "$*"; }
# ----------------------------------------------------------- cleanup hook
cleanup() {
local rc=$?
if [ "${KEEP_RUNNING:-0}" = "1" ]; then
log "KEEP_RUNNING=1 — leaving containers up (project=$COMPOSE_PROJECT)"
return $rc
fi
log "Tearing down compose project $COMPOSE_PROJECT"
# On non-zero exit, capture logs FIRST. Per feedback_image_promote_is_
# not_user_live: dump state from the actually-running container, not
# an inferred pipeline state.
if [ $rc -ne 0 ]; then
log "Canary FAILED — dumping artifacts to $ARTIFACTS_DIR"
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" logs \
--no-color --tail=200 runtime \
> "$ARTIFACTS_DIR/runtime.log" 2>&1 || true
# SessionStore state probe — runtime exposes /admin/session-store
# in canary mode; if not present this 404s and the file is empty.
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" exec -T runtime \
sh -c 'ls -la /tmp/canary-memory 2>/dev/null; find /tmp -name "session*.json" -exec cat {} \; 2>/dev/null' \
> "$ARTIFACTS_DIR/session-store.txt" 2>&1 || true
fi
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" down --volumes --remove-orphans >/dev/null 2>&1 || true
return $rc
}
trap cleanup EXIT
# ------------------------------------------------------ stack bring-up
log "Building cp_sim image"
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" build cp_sim
log "Pulling runtime image: $TEMPLATE_IMAGE"
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" pull runtime 2>&1 \
| tail -5 || true
log "Starting runtime (host port $RUNTIME_PORT)"
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" up -d runtime
# Wait for healthcheck — docker-compose `--wait` is the canonical mechanism
# (introduced in v2.1.1 in 2021, available on every supported runner pool).
log "Waiting for runtime healthcheck"
if ! docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" up -d --wait runtime; then
log "Runtime never went healthy — dumping logs"
docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" logs --no-color --tail=200 runtime \
> "$ARTIFACTS_DIR/runtime-boot-failure.log" 2>&1 || true
exit 2
fi
# -------------------------------------------------------------- run tests
log "Running canary suite"
# Run cp_sim under the same compose project so DNS (runtime hostname)
# resolves on the molecule-core-net bridge. --rm cleans the driver container
# after pytest exits; volume bind mounts pytest's junit-xml back to host.
if docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" --profile driver run \
--rm \
-v "$ARTIFACTS_DIR:/harness/artifacts" \
cp_sim; then
log "All canaries PASSED"
exit 0
else
log "At least one canary FAILED — see $ARTIFACTS_DIR/junit.xml"
exit 1
fi
@@ -1,85 +0,0 @@
name: session-continuity-e2e
# Per-template wrapper for the molecule-core/local-e2e canary harness.
# DO NOT EDIT THIS FILE IN A TEMPLATE REPO — the canonical copy lives at
# molecule-ai/molecule-core:local-e2e/templates/session-continuity-e2e.yml
# (feedback_no_single_source_of_truth). The onboard-template.sh script
# copies it verbatim into each template; future fixes propagate via that
# helper, not by editing the template-side copy.
#
# What this workflow does:
# 1. Build THIS template's runtime image locally on the docker-host runner.
# 2. Clone molecule-core (canonical harness source).
# 3. Invoke local-e2e/scripts/run-canary.sh with TEMPLATE_IMAGE set to
# the just-built local image.
# 4. Upload artifacts/ on failure for post-mortem.
#
# Required-context flip:
# This workflow posts a status under the literal context name
# "session-continuity-e2e (pull_request)" — Gitea's standard
# <workflow-name> (<event>) format. To make it REQUIRED, add that
# exact string to the template repo's branch_protection
# status_check_contexts list. See README.md for the bake-period rule.
#
# Gitea 1.22.6 / act_runner notes (cross-refs to known footguns):
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked) —
# we clone molecule-core via plain git instead.
# - Per-SHA concurrency (feedback_concurrency_group_per_sha).
# - Workflow-level GITHUB_SERVER_URL pinned to the Gitea host
# (feedback_act_runner_github_server_url).
# - Runs on docker-host pool — NOT the heavy CI pool — per CTO
# directive "separate CI as possible" and the <3 min target.
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: session-continuity-e2e-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: true
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
session-continuity-e2e:
runs-on: docker-host
timeout-minutes: 8
steps:
- name: Checkout template
uses: actions/checkout@v4
with:
path: template
- name: Build template image
id: build
working-directory: template
run: |
IMAGE_TAG="local-e2e-${GITHUB_SHA::12}"
docker build -t "molecule-ai/template-under-test:${IMAGE_TAG}" .
echo "image=molecule-ai/template-under-test:${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
- name: Clone harness from molecule-core
run: |
# Anonymous clone — molecule-core is internal-readable. NEVER bake
# an auth token into the URL (feedback_credentials_in_git_url).
git clone --depth 1 "${GITHUB_SERVER_URL}/molecule-ai/molecule-core.git" harness
- name: Run canary
env:
TEMPLATE_IMAGE: ${{ steps.build.outputs.image }}
CANARY_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
cd harness
./local-e2e/scripts/run-canary.sh
- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: session-continuity-canary-${{ github.run_id }}
path: harness/local-e2e/artifacts/
if-no-files-found: warn
retention-days: 7
+3 -1
View File
@@ -28,7 +28,9 @@
{"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": "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"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
+1 -1
View File
@@ -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 claude_code codex hermes openclaw; do
for adapter in langgraph claude_code openclaw deepagents crewai autogen; do
DOCKERFILE="adapters/${adapter}/Dockerfile"
if [ ! -f "$DOCKERFILE" ]; then
echo "Skipping $adapter (no Dockerfile)"
+1 -1
View File
@@ -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 codex hermes openclaw)
ALL_RUNTIMES=(claude-code langgraph crewai autogen deepagents hermes gemini-cli openclaw)
RUNTIMES=("${ALL_RUNTIMES[@]}")
RECREATE=true
+38 -16
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# E2E test: all maintained adapters — create one agent per runtime, test A2A
# E2E test: All 6 adapters — create one agent per runtime, test A2A between all
set -euo pipefail
PLATFORM="${1:-http://localhost:8080}"
@@ -52,12 +52,12 @@ a2a_send() {
}
echo "============================================"
echo " All-Adapters E2E Test (4 runtimes)"
echo " All-Adapters E2E Test (6 runtimes)"
echo "============================================"
echo ""
# --- Create workspaces ---
echo "--- Step 1: Create 4 workspaces ---"
echo "--- Step 1: Create 6 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-Codex","role":"codex test","tier":2,"template":"codex"}')
-d '{"name":"Bob-LangGraph","role":"langgraph test","tier":2,"template":"langgraph"}')
BOB=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check "Create Bob (codex)" "provisioning" "$R"
check "Create Bob (langgraph)" "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,19 +75,29 @@ 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-Hermes","role":"hermes test","tier":2,"template":"hermes"}')
-d '{"name":"Dave-DeepAgents","role":"deepagents test","tier":2,"template":"deepagents"}')
DAVE=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check "Create Dave (hermes)" "provisioning" "$R"
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"
# --- Set API keys (skip Claude which uses OAuth) ---
echo ""
echo "--- Step 2: Set API keys ---"
for ID in $BOB $CAROL $DAVE; do
for ID in $BOB $CAROL $DAVE $EVE $FRANK; 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 3 agents"
echo "Set OPENAI_API_KEY on 5 agents"
# Auto-restart happens automatically when secrets are set
echo "Secrets trigger auto-restart — waiting for agents to come back..."
@@ -95,11 +105,13 @@ sleep 15
# --- Wait for all online ---
echo ""
echo "--- Step 3: Wait for agents (OpenClaw ~3min, Hermes may take longer) ---"
echo "--- Step 3: Wait for agents (OpenClaw ~3min, CrewAI/AutoGen/DeepAgents ~2min) ---"
wait_online "$ALICE" "Alice-Claude" 20 && check "Alice online" "ok" "ok" || check "Alice 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 "$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 "$CAROL" "Carol-OpenClaw" 360 && check "Carol online" "ok" "ok" || check "Carol online" "online" "timeout"
# --- Test A2A messages ---
@@ -111,7 +123,7 @@ RESP=$(a2a_send "$ALICE" "say hello in one word")
echo " -> $RESP"
check "Alice responds" "hello" "$RESP"
echo " Talking to Bob (Codex)..."
echo " Talking to Bob (LangGraph)..."
RESP=$(a2a_send "$BOB" "say hello in one word")
echo " -> $RESP"
check "Bob responds" "hello" "$RESP"
@@ -121,11 +133,21 @@ RESP=$(a2a_send "$CAROL" "say hello in one word")
echo " -> $RESP"
check "Carol responds" "hello" "$RESP"
echo " Talking to Dave (Hermes)..."
echo " Talking to Dave (DeepAgents)..."
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 ---"
@@ -135,7 +157,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 3 peers" "3 peers" "$R"
check "Alice sees 5 peers" "5 peers" "$R"
# --- Isolation ---
echo ""
@@ -146,7 +168,7 @@ check "No ws-* dirs on host" "0" "$HOST_WS"
# --- Cleanup ---
echo ""
echo "--- Step 7: Cleanup ---"
for ID in $ALICE $BOB $CAROL $DAVE; do
for ID in $ALICE $BOB $CAROL $DAVE $EVE $FRANK; do
curl -s -X DELETE "$PLATFORM/workspaces/$ID" > /dev/null 2>&1
done
check "Cleanup" "ok" "ok"
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Stdin: JSON response from token-bearing workspace APIs.
"""Stdin: JSON response from POST /registry/register.
Stdout: the auth_token value, or empty string.
Stderr: diagnostic when the response is unparseable or missing a token.
@@ -18,7 +18,7 @@ except (json.JSONDecodeError, ValueError) as e:
print("")
raise SystemExit(0)
token = data.get("auth_token", "") or data.get("connection", {}).get("auth_token", "")
token = data.get("auth_token", "")
if not token:
sys.stderr.write("e2e_extract_token: response contained no auth_token field\n")
print(token)
+10 -7
View File
@@ -19,27 +19,30 @@ e2e_extract_token() {
# Delete every workspace currently on the platform. Use at the top of a
# script so count-based assertions are reproducible across runs.
# Mint a fresh workspace auth token via the real admin endpoint.
# Mint a fresh workspace auth token via the admin endpoint (issue #6).
# Use this INSTEAD of racing /registry/register from the test harness —
# GET /admin/workspaces/:id/test-token is deterministic and gated by
# MOLECULE_ENV (off in production, on in dev / CI).
#
# Usage:
# TOKEN=$(e2e_mint_workspace_token "$workspace_id") || exit 1
e2e_mint_workspace_token() {
# TOKEN=$(e2e_mint_test_token "$workspace_id") || exit 1
e2e_mint_test_token() {
local wid="$1"
if [ -z "$wid" ]; then
echo "e2e_mint_workspace_token: workspace id required" >&2
echo "e2e_mint_test_token: workspace id required" >&2
return 2
fi
local body
local admin_bearer="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
local admin_auth=()
[ -n "$admin_bearer" ] && admin_auth=(-H "Authorization: Bearer $admin_bearer")
body=$(curl -s -X POST -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/tokens" ${admin_auth[@]+"${admin_auth[@]}"})
body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token" ${admin_auth[@]+"${admin_auth[@]}"})
local code
code=$(printf '%s' "$body" | tail -n1)
local json
json=$(printf '%s' "$body" | sed '$d')
if [ "$code" != "201" ]; then
echo "e2e_mint_workspace_token: got HTTP $code from POST /admin/workspaces/:id/tokens" >&2
if [ "$code" != "200" ]; then
echo "e2e_mint_test_token: got HTTP $code (is MOLECULE_ENV!=production?)" >&2
return 1
fi
printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])"
+10
View File
@@ -10,6 +10,15 @@
# "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"
@@ -42,6 +51,7 @@ pick_model_slug() {
fi
case "$runtime" in
hermes) printf 'openai/gpt-4o' ;;
langgraph) printf 'openai:gpt-4o' ;;
claude-code)
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
printf 'MiniMax-M2'
+4 -81
View File
@@ -2,16 +2,10 @@
# lint_cleanup_traps.sh — regression gate for the OSS-shape program's
# "all E2E tests must have proper cleanup" bar (RFC #2873).
#
# 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.
# 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.
#
# Why this lints (instead of the test runner enforcing): shell scripts
# can't easily be wrapped by an outer harness without breaking the
@@ -27,7 +21,6 @@
set -euo pipefail
cd "$(dirname "$0")"
repo_root="$(cd ../.. && pwd)"
violations=0
for f in test_*.sh; do
@@ -39,76 +32,6 @@ 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
+18 -18
View File
@@ -17,7 +17,7 @@ SUM_URL="https://example.com/summarizer-agent"
# AdminAuth-gated calls need a bearer token once any workspace token
# exists in the DB. ADMIN_TOKEN is populated after the first workspace
# create + real token mint. acurl = "authenticated curl".
# create + test-token mint. acurl = "authenticated curl".
ADMIN_TOKEN=""
acurl() {
if [ -n "$ADMIN_TOKEN" ]; then
@@ -62,10 +62,13 @@ R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{
check "POST /workspaces (create echo)" '"status":"awaiting_agent"' "$R"
ECHO_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
ADMIN_TOKEN=$(echo "$R" | e2e_extract_token)
if [ -z "$ADMIN_TOKEN" ]; then
ADMIN_TOKEN=$(e2e_mint_workspace_token "$ECHO_ID" 2>/dev/null || echo "")
fi
# Mint a test token so all subsequent AdminAuth-gated calls succeed.
# The test-token endpoint is NOT behind AdminAuth (always accessible
# when MOLECULE_ENV != production), so this works even on first boot.
# Debug: show what the test-token endpoint returns
TEST_TOKEN_RAW=$(curl -s "$BASE/admin/workspaces/$ECHO_ID/test-token")
echo " test-token response: $TEST_TOKEN_RAW"
ADMIN_TOKEN=$(echo "$TEST_TOKEN_RAW" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "")
if [ -n "$ADMIN_TOKEN" ]; then
echo " (acquired admin token: ${ADMIN_TOKEN:0:8}...)"
else
@@ -87,25 +90,22 @@ R=$(acurl "$BASE/workspaces/$ECHO_ID")
check "GET /workspaces/:id" '"name":"Echo Agent"' "$R"
check "GET /workspaces/:id (agent_card null)" '"agent_card":null' "$R"
# Test 7: Register echo — use workspace-specific token (from real admin
# Test 7: Register echo — use workspace-specific token (from test-token
# endpoint), not the admin token. C18 requires a token issued TO THIS
# workspace, not just any valid token.
ECHO_WS_TOKEN="$ADMIN_TOKEN"
ECHO_WS_TOKEN=$(curl -s "$BASE/admin/workspaces/$ECHO_ID/test-token" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "")
[ -n "$ECHO_WS_TOKEN" ] && ECHO_AUTH=(-H "Authorization: Bearer $ECHO_WS_TOKEN")
R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
"${ECHO_AUTH[@]}" \
-d "{\"id\":\"$ECHO_ID\",\"url\":\"$ECHO_URL\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}")
check "POST /registry/register (echo)" '"status":"registered"' "$R"
# Extract token from register response; fall back to the workspace token we
# Extract token from register response; fall back to the test-token we
# already minted (register may not return a new token on re-registration).
ECHO_TOKEN=$(echo "$R" | e2e_extract_token)
if [ -z "$ECHO_TOKEN" ]; then ECHO_TOKEN="$ECHO_WS_TOKEN"; fi
# Test 8: Register summarizer — same pattern: workspace-specific token
SUM_WS_TOKEN=$(echo "$R" | e2e_extract_token)
if [ -z "$SUM_WS_TOKEN" ]; then
SUM_WS_TOKEN=$(e2e_mint_workspace_token "$SUM_ID" 2>/dev/null || echo "")
fi
SUM_WS_TOKEN=$(curl -s "$BASE/admin/workspaces/$SUM_ID/test-token" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "")
[ -n "$SUM_WS_TOKEN" ] && SUM_AUTH=(-H "Authorization: Bearer $SUM_WS_TOKEN")
R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
"${SUM_AUTH[@]}" \
@@ -313,8 +313,11 @@ ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.st
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
check "Delete before re-import" '"status":"removed"' "$R"
# After deleting both workspaces, all per-workspace tokens are revoked.
# Clear the now-revoked admin bearer so acurl can use fresh-install fail-open.
# After deleting the last workspace, all per-workspace tokens are revoked.
# But the test-token we minted earlier may still be in the DB as a live
# row (test-token endpoint issues tokens that aren't workspace-scoped
# for revocation). Clear ADMIN_TOKEN so acurl falls back to no-auth,
# which works when HasAnyLiveTokenGlobal = false (fail-open).
ADMIN_TOKEN=""
R=$(acurl "$BASE/workspaces")
COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
@@ -361,10 +364,7 @@ else
fi
# Register the re-imported workspace to verify agent_card round-trips
NEW_TOKEN=$(echo "$R" | e2e_extract_token)
if [ -z "$NEW_TOKEN" ]; then
NEW_TOKEN=$(e2e_mint_workspace_token "$NEW_ID" 2>/dev/null || echo "")
fi
NEW_TOKEN=$(curl -s "$BASE/admin/workspaces/$NEW_ID/test-token" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "")
NEW_AUTH=()
[ -n "$NEW_TOKEN" ] && NEW_AUTH=(-H "Authorization: Bearer $NEW_TOKEN")
R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
+7 -10
View File
@@ -15,7 +15,7 @@
# Required env:
# BASE default http://localhost:8080
# override to https://<id>.<tenant>.staging...
# WORKSPACE_RUNTIME default claude-code (any maintained internal runtime)
# WORKSPACE_RUNTIME default langgraph (any 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:-claude-code}"
RUNTIME="${WORKSPACE_RUNTIME:-langgraph}"
PARENT=""
PARENT_TOK=""
@@ -49,10 +49,9 @@ 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,\"model\":\"sonnet\"}")
-d "{\"name\":\"e2e-chat-upload\",\"runtime\":\"$RUNTIME\",\"tier\":2}")
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; }
PARENT_TOK=$(echo "$P_RESP" | e2e_extract_token)
echo " ✓ workspace=$PARENT"
# ─── 2. Wait for online ────────────────────────────────────────────────
@@ -69,12 +68,10 @@ echo " ✓ online"
# Mint a workspace bearer for the test (the auth needed to call
# /workspaces/:id/chat/uploads, which is wsAuth-gated).
if [ -z "$PARENT_TOK" ]; then
PARENT_TOK=$(e2e_mint_workspace_token "$PARENT") || {
echo " ✗ couldn't mint workspace token"
exit 1
}
fi
PARENT_TOK=$(e2e_mint_test_token "$PARENT") || {
echo " ✗ couldn't mint test token (MOLECULE_ENV=production?)"
exit 1
}
# ─── 3. Upload a fixture ───────────────────────────────────────────────
echo "[3/5] POST /workspaces/$PARENT/chat/uploads ..."
+16 -16
View File
@@ -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 Codex","role":"Test","tier":2,"runtime":"codex"}')
check "Create codex workspace" '"status":"provisioning"' "$R"
RT_CX_ID=$(echo "$R" | jq_extract "['id']")
-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']")
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-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']")
-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']")
# 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_CX_ID" "codex" "codex uses codex image"
_check_image "$RT_HM_ID" "hermes" "hermes uses hermes image"
_check_image "$RT_LG_ID" "langgraph" "langgraph uses langgraph image"
_check_image "$RT_CR_ID" "crewai" "crewai uses crewai 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_CX_ID $RT_HM_ID; do
for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_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_CX_ID/files/config.yaml" \
R=$(curl -s -X PUT "$BASE/workspaces/$RT_LG_ID/files/config.yaml" \
-H "Content-Type: application/json" \
-d '{"content":"name: RT Codex\nruntime: openclaw\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}')
-d '{"content":"name: RT LangGraph\nruntime: deepagents\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}')
if echo "$R" | grep -qF "saved"; then
curl -s -X POST "$BASE/workspaces/$RT_CX_ID/restart" > /dev/null 2>&1
curl -s -X POST "$BASE/workspaces/$RT_LG_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_CX_ID:0:12}"
short_id="${RT_LG_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 "openclaw"; then break; fi
if echo "$actual" | grep -qF "deepagents"; then break; fi
done
_check_image "$RT_CX_ID" "openclaw" "Runtime change codex to openclaw on restart"
_check_image "$RT_LG_ID" "deepagents" "Runtime change langgraph→deepagents 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_CX_ID $RT_HM_ID; do
for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do
curl -s -X DELETE "$BASE/workspaces/$rt_id?confirm=true" > /dev/null 2>&1
sleep 0.3
done
+2 -5
View File
@@ -83,13 +83,10 @@ if [ -z "$WS_ID" ]; then
exit 1
fi
# Ensure a real workspace token exists so AdminAuth now sees a live token. On
# Mint a test-token so AdminAuth now sees a live token on record. On
# pre-fix builds the next /workspaces call would 401 — on post-fix it
# must stay 200 because MOLECULE_ENV=development + ADMIN_TOKEN unset.
TOKEN=$(echo "$BODY" | e2e_extract_token)
if [ -z "$TOKEN" ]; then
e2e_mint_workspace_token "$WS_ID" >/dev/null
fi
curl -s -o /dev/null "$BASE/admin/workspaces/$WS_ID/test-token"
R=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspaces")
check_http "GET /workspaces (after token minted, no bearer)" "200" "$R"
+11 -9
View File
@@ -2,10 +2,12 @@
# 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). 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) 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.
#
# Each branch must FAIL the test if the dispatch behavior changes, not
# just produce some non-empty string.
@@ -45,7 +47,7 @@ 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 "codex → slash-form fallback" codex "openai/gpt-4o"
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "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)
@@ -72,8 +74,8 @@ echo
echo "Test: pick_model_slug — E2E_MODEL_SLUG override"
echo
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="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="custom/whatever" pick_model_slug hermes)
assert_eq "override beats hermes default" "$got" "custom/whatever"
@@ -86,8 +88,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 codex)
assert_eq "empty-string override falls through to dispatch" "$got" "openai/gpt-4o"
got=$(E2E_MODEL_SLUG="" pick_model_slug langgraph)
assert_eq "empty-string override falls through to dispatch" "$got" "openai:gpt-4o"
echo
echo "─────────────────────────────────────────────────"
+8 -10
View File
@@ -94,22 +94,20 @@ 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.
# Body had no runtime → defaults to langgraph; pass the langgraph-compatible
# default that the deleted DefaultModel("") would have returned.
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Notify E2E","tier":1,"runtime":"external","external":true,"model":"sonnet"}')
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
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; }
TOKEN=$(echo "$R" | e2e_extract_token)
echo "Created workspace $WSID"
# Mint a bearer token so the wsAuth-grouped endpoints (notify, activity,
# chat/uploads) accept us. Local dev mode skips auth, but CI enforces it,
# so we always send the header to keep the test portable.
if [ -z "$TOKEN" ]; then
TOKEN=$(e2e_mint_workspace_token "$WSID")
fi
[ -n "$TOKEN" ] || { echo "Failed to mint workspace token"; exit 1; }
# chat/uploads) accept us. Local dev mode skips auth, but CI enforces it
# so we always send the header to keep the test portable. The
# admin/test-token endpoint is only enabled when MOLECULE_ENV != production.
TOKEN=$(e2e_mint_test_token "$WSID")
[ -n "$TOKEN" ] || { echo "Failed to mint test token"; exit 1; }
AUTH="Authorization: Bearer $TOKEN"
echo ""
+33 -14
View File
@@ -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. When a managed runtime create response
# does not expose a one-time workspace token, the same tenant admin/session
# bearer drives the MCP call through WorkspaceAuth. No dev-only admin
# token-mint routes are used in this E2E (feedback_no_dev_only_routes_in_e2e).
# 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
@@ -117,6 +117,27 @@ tenant_call_capture() {
-H "Content-Type: application/json" "$@"
}
redact_token_body() {
python3 -c '
import json, re, sys
raw = sys.stdin.read()
try:
data = json.loads(raw)
except Exception:
print(re.sub(r"(?i)([a-z0-9_]*token)=([^&\\s]+)", r"\1=<redacted>", raw)[:500])
raise SystemExit(0)
def scrub(v):
if isinstance(v, dict):
return {k: ("<redacted>" if "token" in k.lower() else scrub(val)) for k, val in v.items()}
if isinstance(v, list):
return [scrub(x) for x in v]
return v
print(json.dumps(scrub(data), separators=(",", ":"))[:500])
'
}
extract_auth_token() {
python3 -c "
import sys, json
@@ -140,7 +161,7 @@ teardown() {
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
echo ""
log "[teardown] E2E_KEEP_ORG=1 — leaving $SLUG for debugging (REMEMBER TO DELETE)"
exit "$rc"
exit $rc
fi
echo ""
log "[teardown] DELETE /cp/admin/tenants/$SLUG (scoped to this run only)"
@@ -157,13 +178,13 @@ print(sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('instance_status
" 2>/dev/null || echo 1)
if [ "$LEAK" = "0" ]; then
log "[teardown] ✓ $SLUG purged (after ${j}x5s)"
exit "$rc"
exit $rc
fi
sleep 5
done
echo "::warning::[teardown] $SLUG still present after 120s — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES" >&2
[ "$rc" -eq 0 ] && rc=4
exit "$rc"
[ $rc -eq 0 ] && rc=4
exit $rc
}
trap teardown EXIT INT TERM
@@ -249,11 +270,8 @@ for rt in $PV_RUNTIMES; do
-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)
WTOK=$(echo "$R" | extract_auth_token)
[ -n "$WID" ] || fail "$rt workspace create failed: $(printf '%s' "$R" | head -c 300)"
if [ -z "$WTOK" ]; then
log " $rt create response did not include workspace auth_token; using tenant admin/session bearer for MCP auth"
WTOK="$TENANT_TOKEN"
fi
[ -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"
@@ -293,7 +311,8 @@ done
# ─── 6. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
# This is the byte-for-byte user-facing call. NOT GET /registry/:id/peers,
# NOT /health, NOT the heartbeat table. JSON-RPC 2.0 tools/call,
# name=list_peers, authenticated through WorkspaceAuth + MCPRateLimiter.
# name=list_peers, authenticated by the workspace's OWN bearer token
# through WorkspaceAuth + MCPRateLimiter.
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
echo ""
REGRESSED=0
+70 -27
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# E2E test: every maintained runtime template works end-to-end.
# E2E test: every runtime template (8 total) 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,12 +7,13 @@
# extraction (and ongoing template work) can't silently break any
# runtime.
#
# Runtimes covered: claude-code, codex, hermes, openclaw.
# claude-code + hermes have unique
# Runtimes covered: claude-code, hermes, langgraph, crewai, autogen,
# deepagents, openclaw, gemini-cli. 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
# OpenAI-backed runtimes share run_openai_runtime. Each phase skips cleanly
# if its prerequisite secret is missing.
# 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.
#
# What this proves:
# 1. Provisioning + container boot works for each runtime.
@@ -34,7 +35,7 @@
#
# Prereqs:
# - workspace-server on http://localhost:8080
# - AdminAuth bootstrap or `MOLECULE_ADMIN_TOKEN` for token minting
# - MOLECULE_ENV != production (required for admin/test-token)
# - For claude-code: CLAUDE_CODE_OAUTH_TOKEN
# - For hermes: E2E_OPENAI_API_KEY (other providers also OK if you
# set MODEL_SLUG_HERMES + matching secrets directly)
@@ -208,12 +209,9 @@ print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN
pass "claude-code workspace reaches online"
local token
token=$(echo "$resp" | e2e_extract_token)
token=$(e2e_mint_test_token "$wsid")
if [ -z "$token" ]; then
token=$(e2e_mint_workspace_token "$wsid")
fi
if [ -z "$token" ]; then
fail "resolve claude-code workspace token" "no token returned"
fail "mint claude-code test token" "no token returned"
return 0
fi
@@ -276,12 +274,9 @@ print(json.dumps({
pass "hermes workspace reaches online"
local token
token=$(echo "$resp" | e2e_extract_token)
token=$(e2e_mint_test_token "$wsid")
if [ -z "$token" ]; then
token=$(e2e_mint_workspace_token "$wsid")
fi
if [ -z "$token" ]; then
fail "resolve hermes workspace token" "no token returned"
fail "mint hermes test token" "no token returned"
return 0
fi
@@ -301,8 +296,9 @@ print(json.dumps({
####################################################################
# Secondary runtimes — same provision/online/A2A loop, parametrized.
####################################################################
# 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.
# 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.
# 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.
@@ -346,12 +342,9 @@ print(json.dumps({
pass "$runtime workspace reaches online"
local token
token=$(echo "$resp" | e2e_extract_token)
token=$(e2e_mint_test_token "$wsid")
if [ -z "$token" ]; then
token=$(e2e_mint_workspace_token "$wsid")
fi
if [ -z "$token" ]; then
fail "resolve $runtime workspace token" "no token returned"
fail "mint $runtime test token" "no token returned"
return 0
fi
@@ -368,17 +361,67 @@ print(json.dumps({
fi
}
run_codex() { run_openai_runtime "codex" "codex"; }
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_openclaw() { run_openai_runtime "openclaw" "openclaw"; }
WANT="${E2E_RUNTIMES:-claude-code codex hermes 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
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"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}"
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 ;;
all) run_claude_code; run_codex; run_hermes; 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 ;;
*) echo "unknown runtime in E2E_RUNTIMES: $r" >&2; exit 2 ;;
esac
done
-78
View File
@@ -101,14 +101,6 @@ source "$(dirname "$0")/lib/model_slug.sh"
source "$(dirname "$0")/lib/aws_leak_check.sh"
CURL_COMMON=(-sS --fail-with-body --max-time 30)
E2E_TMP_FILES=()
e2e_tmp() {
local f
f=$(mktemp "$1")
E2E_TMP_FILES+=("$f")
printf '%s' "$f"
}
# ─── cleanup trap ───────────────────────────────────────────────────────
CLEANUP_DONE=0
@@ -121,8 +113,6 @@ cleanup_org() {
if [ "$CLEANUP_DONE" = "1" ]; then return 0; fi
CLEANUP_DONE=1
rm -f "${E2E_TMP_FILES[@]}" 2>/dev/null || true
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
log "E2E_KEEP_ORG=1 — skipping teardown. Manually delete $SLUG when done."
return 0
@@ -553,74 +543,6 @@ 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[@]}"
# ─── 7a. Real chat image upload/download round-trip ───────────────────
# This deliberately uses the production workflow: tenant admin/session auth
# uploads an image through the same /chat/uploads path the canvas uses. The
# byte-for-byte download check proves the platform delivered image bytes, not
# just metadata/name plumbing.
log "7a/11 Real image upload/download round-trip..."
PNG_FIXTURE=$(e2e_tmp /tmp/molecule-e2e-image.XXXXXX.png)
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lCqT+wAAAABJRU5ErkJggg==' | base64 -d > "$PNG_FIXTURE"
PNG_SHA=$(sha256sum "$PNG_FIXTURE" | awk '{print $1}')
for wid in "${WS_TO_CHECK[@]}"; do
UP_TMP=$(e2e_tmp /tmp/e2e_upload.XXXXXX)
UP_CODE=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$wid/chat/uploads" \
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-F "files=@$PNG_FIXTURE;filename=e2e-smoke.png;type=image/png" \
-o "$UP_TMP" \
-w '%{http_code}' \
2>/dev/null || echo "000")
if [ "$UP_CODE" != "200" ] && [ "$UP_CODE" != "201" ]; then
fail "Workspace $wid image upload returned $UP_CODE: $(head -c 500 "$UP_TMP" | sanitize_http_body)"
fi
UP_URI=$(python3 -c "
import json, sys
d=json.load(open(sys.argv[1]))
def walk(x):
if isinstance(x, dict):
if x.get('uri'):
print(x['uri']); raise SystemExit
for v in x.values(): walk(v)
elif isinstance(x, list):
for v in x: walk(v)
walk(d)
" "$UP_TMP" 2>/dev/null || echo "")
UP_MIME=$(python3 -c "
import json, sys
d=json.load(open(sys.argv[1]))
def walk(x):
if isinstance(x, dict) and x.get('uri'):
print(x.get('mimeType') or x.get('mime') or ''); raise SystemExit
if isinstance(x, dict):
for v in x.values(): walk(v)
elif isinstance(x, list):
for v in x: walk(v)
walk(d)
" "$UP_TMP" 2>/dev/null || echo "")
rm -f "$UP_TMP"
[ -n "$UP_URI" ] || fail "Workspace $wid upload response had no workspace URI"
[ "$UP_MIME" = "image/png" ] || fail "Workspace $wid upload returned mime=$UP_MIME, want image/png"
DOWNLOAD_PATH="$UP_URI"
case "$DOWNLOAD_PATH" in workspace:*) DOWNLOAD_PATH="${DOWNLOAD_PATH#workspace:}" ;; esac
DL_TMP=$(e2e_tmp /tmp/e2e_download.XXXXXX.png)
DL_CODE=$(curl "${CURL_COMMON[@]}" "$TENANT_URL/workspaces/$wid/chat/download?path=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$DOWNLOAD_PATH")" \
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-o "$DL_TMP" \
-w '%{http_code}' \
2>/dev/null || echo "000")
if [ "$DL_CODE" != "200" ]; then
fail "Workspace $wid image download returned $DL_CODE: $(head -c 300 "$DL_TMP" | sanitize_http_body)"
fi
DL_SHA=$(sha256sum "$DL_TMP" | awk '{print $1}')
rm -f "$DL_TMP"
[ "$DL_SHA" = "$PNG_SHA" ] || fail "Workspace $wid image download SHA mismatch: upload=$PNG_SHA download=$DL_SHA"
ok " $wid image upload/download OK ($UP_MIME, sha256=$DL_SHA)"
done
rm -f "$PNG_FIXTURE"
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
# This step exists because the canvas-terminal failure of 2026-05-03
# was structurally invisible to local-dev (handleLocalConnect uses
+8 -20
View File
@@ -87,10 +87,7 @@ R=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: appl
check "POST /workspaces (alpha)" '"status":"awaiting_agent"' "$R"
WS_A_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
if [ -n "$WS_A_ID" ]; then
WS_A_TOKEN=$(echo "$R" | e2e_extract_token)
if [ -z "$WS_A_TOKEN" ]; then
WS_A_TOKEN=$(e2e_mint_workspace_token "$WS_A_ID" 2>/dev/null || true)
fi
WS_A_TOKEN=$(e2e_mint_test_token "$WS_A_ID" 2>/dev/null || true)
[ -n "$WS_A_TOKEN" ] && WS_A_AUTH=(-H "Authorization: Bearer $WS_A_TOKEN")
if [ -z "$ADMIN_BEARER" ] && [ -n "$WS_A_TOKEN" ]; then
ADMIN_AUTH=(-H "Authorization: Bearer $WS_A_TOKEN")
@@ -102,10 +99,7 @@ R=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: appl
check "POST /workspaces (beta)" '"status":"awaiting_agent"' "$R"
WS_B_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
if [ -n "$WS_B_ID" ]; then
WS_B_TOKEN=$(echo "$R" | e2e_extract_token)
if [ -z "$WS_B_TOKEN" ]; then
WS_B_TOKEN=$(e2e_mint_workspace_token "$WS_B_ID" 2>/dev/null || true)
fi
WS_B_TOKEN=$(e2e_mint_test_token "$WS_B_ID" 2>/dev/null || true)
[ -n "$WS_B_TOKEN" ] && WS_B_AUTH=(-H "Authorization: Bearer $WS_B_TOKEN")
fi
@@ -263,18 +257,12 @@ if [ -n "${WS_A_ID:-}" ]; then
if [ -n "$DEBUG" ] && echo "$DEBUG" | grep -q "workspace_secrets"; then
# Presence-only check: KEY in the secrets map, value MAY be empty
# in dev where no persona is bound.
if echo "$DEBUG" | grep -q '"GIT_HTTP_USERNAME"'; then
echo "PASS: ws-secrets carries GIT_HTTP_USERNAME key (mc#1542)"
PASS=$((PASS+1))
else
echo "INFO: GIT_HTTP_USERNAME not in debug secrets (no persona bound in dev) — non-fatal"
fi
if echo "$DEBUG" | grep -q '"GIT_ASKPASS"'; then
echo "PASS: ws-secrets carries GIT_ASKPASS path (mc#1525)"
PASS=$((PASS+1))
else
echo "INFO: GIT_ASKPASS path not in debug surface — runtime image may set it directly"
fi
echo "$DEBUG" | grep -q '"GIT_HTTP_USERNAME"' \
&& { echo "PASS: ws-secrets carries GIT_HTTP_USERNAME key (mc#1542)"; PASS=$((PASS+1)); } \
|| { echo "INFO: GIT_HTTP_USERNAME not in debug secrets (no persona bound in dev) — non-fatal"; }
echo "$DEBUG" | grep -q '"GIT_ASKPASS"' \
&& { echo "PASS: ws-secrets carries GIT_ASKPASS path (mc#1525)"; PASS=$((PASS+1)); } \
|| { echo "INFO: GIT_ASKPASS path not in debug surface — runtime image may set it directly"; }
else
echo "INFO: admin debug surface unavailable — cannot probe ws-secrets (non-fatal)"
fi
+10 -17
View File
@@ -25,8 +25,6 @@ PASS=0
FAIL=0
SENDER_ID=""
RECEIVER_ID=""
SENDER_TOKEN=""
RECEIVER_TOKEN=""
cleanup() {
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
@@ -96,27 +94,24 @@ R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Sender","tier":1}')
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
SENDER_TOKEN=$(echo "$R" | e2e_extract_token)
echo "Created sender workspace: $SENDER_ID"
# Admin token — any live workspace bearer satisfies AdminAuth in local dev.
# In production-like envs, set MOLECULE_ADMIN_TOKEN.
if [ -z "$SENDER_TOKEN" ]; then
SENDER_TOKEN=$(e2e_mint_workspace_token "$SENDER_ID")
fi
[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; }
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
R=$(curl -s -X POST "$BASE/workspaces" -H "$ADMIN_AUTH" -H "Content-Type: application/json" \
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Receiver","tier":1}')
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
RECEIVER_TOKEN=$(echo "$R" | e2e_extract_token)
echo "Created receiver workspace: $RECEIVER_ID"
# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod).
SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID")
[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; }
SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN"
# Admin token — any live workspace bearer satisfies AdminAuth in local dev.
# In production-like envs, set MOLECULE_ADMIN_TOKEN.
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Part 1: talk_to_user ability ==="
@@ -211,9 +206,7 @@ fi
echo ""
echo "--- 2d: Receiver activity log has broadcast_receive entry ---"
if [ -z "$RECEIVER_TOKEN" ]; then
RECEIVER_TOKEN=$(e2e_mint_workspace_token "$RECEIVER_ID")
fi
RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID")
[ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; }
RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN"
+4 -5
View File
@@ -1,4 +1,3 @@
# shellcheck shell=bash
# Sourceable helper for harness replays. Centralises the
# curl-against-cf-proxy pattern so scripts don't depend on /etc/hosts.
#
@@ -119,11 +118,11 @@ curl_beta_creds_at_alpha() {
# ─── Workspace-scoped (per-workspace bearer) ──────────────────────────
# Workspace-scoped request to alpha — uses a real per-workspace bearer minted
# through the admin token route, not the local-dev fixture-token route. Caller
# must export WORKSPACE_TOKEN.
# Workspace-scoped request to alpha — uses a per-workspace bearer
# minted from /admin/workspaces/:id/test-token. Caller must export
# WORKSPACE_TOKEN.
curl_workspace() {
: "${WORKSPACE_TOKEN:?WORKSPACE_TOKEN must be set — mint via POST /admin/workspaces/:id/tokens}"
: "${WORKSPACE_TOKEN:?WORKSPACE_TOKEN must be set — mint via /admin/workspaces/:id/test-token}"
curl -sS \
-H "Host: ${TENANT_HOST}" \
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
+5 -21
View File
@@ -26,7 +26,7 @@
# B. Org-header mismatch — alpha-org header at beta's URL → 404.
# C. Reverse — beta-org header at alpha's URL → 404.
# D. Right URL, wrong org header (typo) → 404.
# E. Bearer present but no org header → 400 with an actionable JSON error.
# E. Bearer present but no org header → 404 (TenantGuard rejects).
# F. Per-tenant DB isolation — alpha's /workspaces enumerates only
# alpha workspaces; beta's only beta. Confirms cf-proxy + TenantGuard
# really did partition the request to the right backing DB.
@@ -44,12 +44,8 @@ if [ ! -f .seed.env ]; then
fi
# shellcheck source=/dev/null
source .seed.env
# shellcheck disable=SC1091
# shellcheck source=../_curl.sh
source "$HARNESS_ROOT/_curl.sh"
# shellcheck disable=SC2153
: "${ALPHA_HOST:?}"
# shellcheck disable=SC2153
: "${BETA_HOST:?}"
PASS=0
FAIL=0
@@ -125,27 +121,15 @@ GARBAGE=$(curl -sS -o /dev/null -w '%{http_code}' \
"$BASE/workspaces")
assert_status "D1: garbage org id at alpha URL → 404" "404" "$GARBAGE"
# ─── Phase E: bearer present but no org header at all → 400 ────────────
# ─── Phase E: bearer present but no org header at all → 404 ────────────
echo ""
echo "[replay] E. valid bearer but missing X-Molecule-Org-Id → 400"
echo "[replay] E. valid bearer but missing X-Molecule-Org-Id → 404"
NO_ORG=$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Host: ${ALPHA_HOST}" \
-H "Authorization: Bearer ${ALPHA_ADMIN_TOKEN}" \
"$BASE/workspaces")
assert_status "E1: missing X-Molecule-Org-Id → 400" "400" "$NO_ORG"
NO_ORG_BODY=$(curl -sS \
-H "Host: ${ALPHA_HOST}" \
-H "Authorization: Bearer ${ALPHA_ADMIN_TOKEN}" \
"$BASE/workspaces")
if echo "$NO_ORG_BODY" | jq -e '.code == "TENANT_ORG_HEADER_REQUIRED" and .required_header == "X-Molecule-Org-Id"' >/dev/null; then
printf " PASS E2: missing-header body names the required tenant header\n"
PASS=$((PASS + 1))
else
printf " FAIL E2: missing-header body should explain X-Molecule-Org-Id\n body: %s\n" "$NO_ORG_BODY" >&2
FAIL=$((FAIL + 1))
fi
assert_status "E1: missing X-Molecule-Org-Id → 404" "404" "$NO_ORG"
# ─── Phase F: per-tenant DB isolation via list_workspaces ──────────────
echo ""
+2 -2
View File
@@ -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\":\"claude-code\",\"model\":\"sonnet\"}"
body="{\"name\":\"$name\",\"tier\":$tier,\"parent_id\":\"$parent\",\"runtime\":\"langgraph\"}"
else
body="{\"name\":\"$name\",\"tier\":$tier,\"runtime\":\"claude-code\",\"model\":\"sonnet\"}"
body="{\"name\":\"$name\",\"tier\":$tier,\"runtime\":\"langgraph\"}"
fi
local id
if [ "$tenant" = "alpha" ]; then

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