Compare commits

..

9 Commits

Author SHA1 Message Date
fullstack-engineer a60033dc16 test(handlers): add missing DB-error tests for Record and SessionSearch
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Waiting to run
publish-runtime-autobump / bump-and-tag (pull_request) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 3m8s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m22s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m11s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m41s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m6s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m59s
audit-force-merge / audit (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 34s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 43s
CI / Detect changes (pull_request) Successful in 1m13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m16s
Harness Replays / detect-changes (pull_request) Successful in 53s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m57s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 58s
qa-review / approved (pull_request) Failing after 48s
gate-check-v3 / gate-check (pull_request) Failing after 55s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m51s
security-review / approved (pull_request) Failing after 41s
sop-tier-check / tier-check (pull_request) Successful in 40s
CI / Python Lint & Test (pull_request) Successful in 8m14s
CI / Canvas (Next.js) (pull_request) Successful in 19m47s
CI / Platform (Go) (pull_request) Successful in 21m16s
CI / all-required (pull_request) Successful in 21m27s
CI / Canvas Deploy Reminder (pull_request) Successful in 8s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
- TestDelegationRecord_DBInsertFails: verifies 500 on activity_logs insert failure
- TestSessionSearch_DBError: verifies 500 on WITH query failure

Both are regression coverage for error paths that lacked test coverage.

🤖 Generated with [Claude Code](https://claude.ai/claude-code)
2026-05-15 07:38:15 +00:00
app-fe 5dc1e462de fix(external-workspace): pin molecule-ai-workspace-runtime>=0.1.999 in OpenClaw snippet (#1143)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
gate-check-v3 / gate-check (push) Has started running
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Has started running
ci-required-drift / drift (push) Has started running
Runtime Pin Compatibility / PyPI-latest install + import smoke (push) Has started running
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Has started running
Block internal-flavored paths / Block forbidden paths (push) Successful in 17s
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Successful in 35s
Handlers Postgres Integration / detect-changes (push) Successful in 25s
Harness Replays / detect-changes (push) Successful in 22s
CI / Detect changes (push) Successful in 58s
E2E API Smoke Test / detect-changes (push) Successful in 59s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m1s
Harness Replays / Harness Replays (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4m7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 6m36s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7m11s
CI / Python Lint & Test (push) Successful in 8m23s
publish-workspace-server-image / build-and-push (push) Successful in 13m9s
CI / Canvas (Next.js) (push) Successful in 21m39s
CI / Platform (Go) (push) Successful in 23m7s
CI / all-required (push) Successful in 27m51s
CI / Canvas Deploy Reminder (push) Successful in 13s
main-red-watchdog / watchdog (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 28s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 1m0s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m52s
gitea-merge-queue / queue (push) Successful in 27s
status-reaper / reap (push) Successful in 3m26s
fix(external-workspace): pin molecule-ai-workspace-runtime>=0.1.999 in OpenClaw snippet

Ensures the molecule-mcp console script (heartbeat + register-on-startup) is present on install. Older versions only ship a2a_mcp_server which does not heartbeat, causing workspaces to go OFFLINE within 60s.

Closes openclaw keepalive regression.
Co-authored-by: Molecule AI App-FE <app-fe@agents.moleculesai.app>
Co-committed-by: Molecule AI App-FE <app-fe@agents.moleculesai.app>
2026-05-15 07:35:57 +00:00
devops-engineer ec96a8f600 Merge pull request 'fix(ci): throttle SOP refire workflow fan-out' (#1134) from fix/ci-sop-refire-concurrency into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 27s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 19s
CI / Detect changes (push) Successful in 46s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 47s
E2E API Smoke Test / detect-changes (push) Successful in 54s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 49s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m43s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 3m6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m19s
CI / Python Lint & Test (push) Successful in 7m26s
publish-workspace-server-image / build-and-push (push) Successful in 11m8s
CI / Canvas (Next.js) (push) Successful in 19m19s
CI / Canvas Deploy Reminder (push) Successful in 8s
CI / Platform (Go) (push) Successful in 21m11s
CI / all-required (push) Successful in 21m44s
publish-workspace-server-image / Production auto-deploy (push) Successful in 9m57s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 23s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m4s
main-red-watchdog / watchdog (push) Successful in 58s
gate-check-v3 / gate-check (push) Successful in 1m24s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 35s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 15m54s
ci-required-drift / drift (push) Successful in 1m58s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m54s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m32s
gitea-merge-queue / queue (push) Successful in 31s
status-reaper / reap (push) Successful in 2m51s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m55s
2026-05-15 05:57:38 +00:00
claude-ceo-assistant 3198a3ee5d fix(ci): throttle SOP refire workflow fan-out
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 29s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1m21s
CI / Detect changes (pull_request) Successful in 1m29s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 55s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 25s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m10s
sop-checklist / all-items-acked (pull_request) Successful in 23s
sop-tier-check / tier-check (pull_request) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 15s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 58s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m38s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m55s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m59s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m54s
CI / Python Lint & Test (pull_request) Successful in 7m53s
qa-review / approved (pull_request) Approved by core-qa review #3561
security-review / approved (pull_request) Approved by core-security review #3562
CI / Platform (Go) (pull_request) Successful in 13m43s
CI / Canvas (Next.js) (pull_request) Successful in 14m23s
CI / Canvas Deploy Reminder (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 14m35s
audit-force-merge / audit (pull_request) Successful in 18s
2026-05-14 22:39:05 -07:00
hongming-codex-laptop 85b93feacc ci: prevent advisory workflow timeout flakes
dev-lead/test orchestrator test
Block internal-flavored paths / Block forbidden paths (push) Successful in 15s
CI / Detect changes (push) Successful in 27s
CI / Shellcheck (E2E scripts) (push) Successful in 36s
Handlers Postgres Integration / detect-changes (push) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 31s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 41s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 23s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 50s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m53s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m26s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m2s
CI / Python Lint & Test (push) Successful in 7m33s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m59s
publish-workspace-server-image / build-and-push (push) Successful in 12m40s
CI / Canvas (Next.js) (push) Successful in 18m53s
CI / Platform (Go) (push) Successful in 21m52s
CI / Canvas Deploy Reminder (push) Successful in 6s
CI / all-required (push) Successful in 21m53s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m15s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 2m13s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 1m32s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 42s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Failing after 14m9s
ci-required-drift / drift (push) Successful in 2m7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
main-red-watchdog / watchdog (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
gitea-merge-queue / queue (push) Successful in 10s
status-reaper / reap (push) Successful in 1m28s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m16s
2026-05-14 20:00:19 -07:00
hongming-codex-laptop da2fefa398 ci: harden scheduled gate check against list timeouts
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 28s
CI / Shellcheck (E2E scripts) (push) Successful in 1m1s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 32s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 37s
CI / Detect changes (push) Successful in 2m24s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 2m16s
E2E API Smoke Test / detect-changes (push) Successful in 2m18s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 2m12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m44s
CI / Python Lint & Test (push) Successful in 8m40s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 20s
Handlers Postgres Integration / detect-changes (push) Failing after 10m24s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Failing after 10m19s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 36s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m58s
publish-workspace-server-image / build-and-push (push) Successful in 13m23s
CI / Canvas (Next.js) (push) Successful in 19m24s
CI / Canvas Deploy Reminder (push) Successful in 12s
CI / Platform (Go) (push) Successful in 21m49s
CI / all-required (push) Successful in 21m50s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m6s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 16s
gitea-merge-queue / queue (push) Successful in 33s
status-reaper / reap (push) Successful in 2m23s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m22s
2026-05-14 19:31:31 -07:00
hongming-codex-laptop 8f4c00ba05 ci: rearm after runner disk gc
CI / Canvas Deploy Reminder (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 25s
CI / Shellcheck (E2E scripts) (push) Successful in 43s
CI / Detect changes (push) Successful in 1m16s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 22s
E2E API Smoke Test / detect-changes (push) Successful in 1m20s
Handlers Postgres Integration / detect-changes (push) Successful in 58s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 50s
CI / Python Lint & Test (push) Successful in 8m9s
publish-workspace-server-image / build-and-push (push) Successful in 13m23s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 1m10s
CI / Platform (Go) (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / all-required (push) Has been cancelled
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 31s
ci-required-drift / drift (push) Successful in 2m47s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-14 19:06:22 -07:00
hongming-codex-laptop 106baadf2b ci: fix platform staticcheck lint
Block internal-flavored paths / Block forbidden paths (push) Successful in 26s
CI / Detect changes (push) Successful in 1m5s
CI / Shellcheck (E2E scripts) (push) Successful in 50s
Harness Replays / detect-changes (push) Successful in 28s
E2E API Smoke Test / detect-changes (push) Successful in 54s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 23s
Handlers Postgres Integration / detect-changes (push) Successful in 1m8s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 48s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m9s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 24s
ci-required-drift / drift (push) Successful in 2m22s
CI / Python Lint & Test (push) Successful in 7m52s
publish-workspace-server-image / build-and-push (push) Successful in 10m3s
CI / Canvas (Next.js) (push) Successful in 18m10s
Harness Replays / Harness Replays (push) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 20s
CI / Platform (Go) (push) Successful in 19m30s
CI / all-required (push) Successful in 19m25s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 4m12s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 5m31s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m22s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 21s
CI / Canvas Deploy Reminder (push) Successful in 11s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 16s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m57s
main-red-watchdog / watchdog (push) Successful in 1m18s
2026-05-14 18:12:21 -07:00
hongming-codex-laptop 6b80dca1f4 fix: preserve Claude Code provider registry in generated configs
CI / Canvas Deploy Reminder (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 18s
CI / Shellcheck (E2E scripts) (push) Successful in 37s
CI / Detect changes (push) Successful in 1m6s
Harness Replays / detect-changes (push) Successful in 17s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m3s
E2E API Smoke Test / detect-changes (push) Successful in 1m9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m12s
Handlers Postgres Integration / detect-changes (push) Successful in 57s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 52s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m42s
CI / Python Lint & Test (push) Successful in 7m49s
publish-workspace-server-image / build-and-push (push) Successful in 9m37s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 23s
CI / Canvas (Next.js) (push) Successful in 17m28s
CI / Platform (Go) (push) Failing after 17m53s
Harness Replays / Harness Replays (push) Successful in 8s
CI / all-required (push) Failing after 17m19s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m31s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m57s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 5m20s
main-red-watchdog / watchdog (push) Successful in 1m11s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-14 17:33:22 -07:00
31 changed files with 306 additions and 943 deletions
+27 -11
View File
@@ -83,25 +83,41 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Fetch all open PRs and run gate-check on each
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# gate_check.py uses timeout=15 on every urlopen call; this catches the
# inline Python polling loop too (issue #603).
# Fetch all open PRs and run gate-check on each. This scheduled
# refresher is advisory; a transient Gitea list timeout must not turn
# main red. PR-specific gate-check runs still use normal failure
# semantics.
pr_numbers=$(python3 <<'PY'
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.request
socket.setdefaulttimeout(15)
socket.setdefaulttimeout(30)
token = os.environ["GITEA_TOKEN"]
repo = os.environ["REPO"]
req = urllib.request.Request(
f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req) as r:
prs = json.loads(r.read())
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100"
last_error = None
for attempt in range(1, 4):
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
prs = json.loads(r.read())
break
except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
last_error = exc
print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr)
if attempt < 3:
time.sleep(2 * attempt)
else:
print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr)
raise SystemExit(0)
for pr in prs:
print(pr["number"])
PY
@@ -86,7 +86,11 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# A full-history checkout can exceed the runner's quiet/startup
# window before the path filter emits logs. Fetch the common push
# case cheaply; the script below fetches the exact BASE SHA if it is
# not present in the shallow checkout.
fetch-depth: 2
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
@@ -93,7 +93,7 @@ jobs:
lint:
name: lint-continue-on-error-tracking
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
# Phase 3 (RFC #219 §1): surface masked defects without blocking
# PRs. Pre-existing continue-on-error: true directives on main
# all violate this lint at first — intentional. Flip to false
@@ -18,6 +18,10 @@ permissions:
pull-requests: read
statuses: write
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: true
jobs:
dispatch:
runs-on: ubuntu-latest
+1 -1
View File
@@ -70,7 +70,7 @@ name: sop-checklist
# Cancel any in-progress runs for the same PR to prevent
# stale runs from overwriting newer status contexts.
concurrency:
group: ${{ github.repository }}-${{ github.event.pull_request.number }}
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: true
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
+4
View File
@@ -61,6 +61,10 @@ on:
pull_request_review:
types: [submitted, dismissed, edited]
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tier-check:
runs-on: ubuntu-latest
+9 -305
View File
@@ -6,13 +6,9 @@
// attachments, no A2A topology overlay, no conversation tracing.
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { uploadChatFiles } from "@/components/tabs/chat/uploads";
import type { ChatAttachment } from "@/components/tabs/chat/types";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
@@ -23,7 +19,6 @@ interface ChatMessage {
role: "user" | "agent" | "system";
text: string;
ts: string;
attachments?: ChatAttachment[];
}
const formatStoredTimestamp = (iso: string): string => {
@@ -44,171 +39,6 @@ interface A2AResponseShape {
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
function MarkdownBubble({
children,
dark,
accent,
}: {
children: string;
dark: boolean;
accent: string;
}) {
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
const linkColor = accent;
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: linkColor, textDecoration: "underline" }}
>
{children}
</a>
),
pre: ({ children }) => (
<pre
style={{
background: codeBlockBg,
padding: "8px 10px",
borderRadius: 8,
overflow: "auto",
fontSize: 12,
lineHeight: 1.5,
fontFamily: MOBILE_FONT_MONO,
margin: "4px 0",
}}
>
{children}
</pre>
),
code: ({ children, className }) => {
const isBlock = className != null && String(className).length > 0;
if (isBlock) {
return (
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
{children}
</code>
);
}
return (
<code
style={{
background: codeBg,
padding: "1px 4px",
borderRadius: 4,
fontSize: 13,
fontFamily: MOBILE_FONT_MONO,
}}
>
{children}
</code>
);
},
ul: ({ children }) => (
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
{children}
</ol>
),
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
strong: ({ children }) => (
<strong style={{ fontWeight: 600 }}>{children}</strong>
),
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
h1: ({ children }) => (
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h2: ({ children }) => (
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h3: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h4: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h5: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h6: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
blockquote: ({ children }) => (
<blockquote
style={{
borderLeft: `2px solid ${quoteBorder}`,
margin: "4px 0",
paddingLeft: 8,
opacity: 0.85,
}}
>
{children}
</blockquote>
),
hr: () => (
<hr
style={{
border: "none",
borderTop: `0.5px solid ${quoteBorder}`,
margin: "6px 0",
}}
/>
),
table: ({ children }) => (
<table
style={{
borderCollapse: "collapse",
fontSize: 13,
margin: "4px 0",
width: "100%",
}}
>
{children}
</table>
),
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
th: ({ children }) => (
<th
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
textAlign: "left",
}}
>
{children}
</th>
),
td: ({ children }) => (
<td
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
}}
>
{children}
</td>
),
}}
>
{children}
</ReactMarkdown>
);
}
export function MobileChat({
agentId,
dark,
@@ -224,7 +54,6 @@ export function MobileChat({
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [historyLoading, setHistoryLoading] = useState(true);
const [historyError, setHistoryError] = useState<string | null>(null);
@@ -235,8 +64,6 @@ export function MobileChat({
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -344,82 +171,30 @@ export function MobileChat({
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
});
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
const send = async () => {
const text = draft.trim();
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
setError(null);
setSending(true);
let uploaded: ChatAttachment[] = [];
if (pendingFiles.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(agentId, pendingFiles);
} catch (e) {
setError(e instanceof Error ? e.message : "Upload failed");
setSending(false);
setUploading(false);
sendInFlightRef.current = false;
return;
} finally {
setUploading(false);
}
setPendingFiles([]);
}
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
attachments: uploaded.length > 0 ? uploaded : undefined,
};
setMessages((m) => [...m, myMsg]);
try {
const parts: Array<
| { kind: "text"; text: string }
| {
kind: "file";
file: { name: string; mimeType?: string; uri: string; size?: number };
}
> = [];
if (text) parts.push({ kind: "text", text });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
parts: [{ kind: "text", text }],
},
},
});
@@ -627,9 +402,7 @@ export function MobileChat({
overflowWrap: "anywhere",
}}
>
<MarkdownBubble dark={dark} accent={p.accent}>
{m.text}
</MarkdownBubble>
{m.text}
<div
style={{
fontSize: 10,
@@ -687,60 +460,6 @@ export function MobileChat({
backdropFilter: "blur(14px)",
}}
>
{pendingFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
paddingLeft: 2,
}}
>
{pendingFiles.map((f, i) => (
<div
key={`${f.name}:${f.size}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 10,
background: dark ? "#2a2823" : "#ece9e0",
fontSize: 12,
color: p.text2,
maxWidth: "100%",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{f.name}
</span>
<button
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
style={{
border: "none",
background: "transparent",
color: p.text3,
cursor: "pointer",
fontSize: 12,
padding: 0,
lineHeight: 1,
}}
>
</button>
</div>
))}
</div>
)}
<div
style={{
display: "flex",
@@ -752,32 +471,21 @@ export function MobileChat({
padding: "6px 6px 6px 12px",
}}
>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => onFilesPicked(e.target.files)}
aria-hidden="true"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: !reachable || sending || uploading ? 0.4 : 1,
}}
>
{Icons.attach({ size: 16 })}
@@ -823,32 +531,28 @@ export function MobileChat({
<button
type="button"
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{uploading ? (
<span style={{ fontSize: 10, fontWeight: 600 }}></span>
) : (
Icons.send({ size: 16 })
)}
{Icons.send({ size: 16 })}
</button>
</div>
</div>
@@ -14,18 +14,16 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ActivityHandler struct {
broadcaster *events.Broadcaster
notifier *push.Notifier
}
func NewActivityHandler(b *events.Broadcaster, notifier *push.Notifier) *ActivityHandler {
return &ActivityHandler{broadcaster: b, notifier: notifier}
func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
return &ActivityHandler{broadcaster: b}
}
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
@@ -478,7 +476,7 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
for _, a := range body.Attachments {
attachments = append(attachments, AgentMessageAttachment(a))
}
writer := NewAgentMessageWriter(db.DB, h.broadcaster, h.notifier)
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
if errors.Is(err, ErrWorkspaceNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
@@ -40,7 +40,7 @@ func TestActivityHandler_SinceID_ReturnsNewerASC(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -69,7 +69,7 @@ func TestActivityHandler_SinceID_CursorNotFound_410(t *testing.T) {
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -101,7 +101,7 @@ func TestActivityHandler_SinceID_CrossWorkspaceCursor_410(t *testing.T) {
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -137,7 +137,7 @@ func TestActivityHandler_SinceID_CombinedWithSinceSecs(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -41,7 +41,7 @@ func TestActivityHandler_SinceSecs_Accepted(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -70,7 +70,7 @@ func TestActivityHandler_SinceSecs_ClampedAt30Days(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -106,7 +106,7 @@ func TestActivityHandler_SinceSecs_InvalidRejected(t *testing.T) {
// No DB call expected; bad input must be caught before the query.
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -142,7 +142,7 @@ func TestActivityHandler_SinceSecs_Omitted(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -22,7 +22,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
rows := sqlmock.NewRows([]string{
"kind", "id", "workspace_id", "label", "content", "method", "status", "request_body", "response_body", "created_at",
@@ -63,12 +63,37 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
}
}
func TestSessionSearch_DBError(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery("WITH session_items AS").
WillReturnError(context.DeadlineExceeded)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/session-search?q=test", bytes.NewBufferString(""))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
handler.SessionSearch(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on DB error, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ---------- Activity List source filter ----------
func TestActivityList_SourceCanvas(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Expect query with "source_id IS NULL"
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NULL`).
@@ -97,7 +122,7 @@ func TestActivityList_SourceCanvas(t *testing.T) {
func TestActivityList_SourceAgent(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Expect query with "source_id IS NOT NULL"
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NOT NULL`).
@@ -126,7 +151,7 @@ func TestActivityList_SourceAgent(t *testing.T) {
func TestActivityList_SourceInvalid(t *testing.T) {
gin.SetMode(gin.TestMode)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -142,7 +167,7 @@ func TestActivityList_SourceInvalid(t *testing.T) {
func TestActivityList_SourceWithType(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Both type and source filters
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NULL`).
@@ -181,7 +206,7 @@ const testPeerUUID = "11111111-2222-3333-4444-555555555555"
func TestActivityList_PeerIDFilter(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// peer_id binds twice in the query (source_id OR target_id) but is
// added to args once — sqlmock matches positional args, so the
@@ -220,7 +245,7 @@ func TestActivityList_PeerIDComposesWithType(t *testing.T) {
// of the builder can't silently rearrange placeholders.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(
`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NOT NULL AND \(source_id = .+ OR target_id = .+\)`,
@@ -258,7 +283,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
// otherwise interpolate the value into the URL or another query.
gin.SetMode(gin.TestMode)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
for _, bad := range []string{
"not-a-uuid",
@@ -292,7 +317,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
func TestActivityList_BeforeTSFilter(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
mock.ExpectQuery(
@@ -328,7 +353,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
// can't silently drop one filter or reorder placeholders.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
mock.ExpectQuery(
@@ -363,7 +388,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
for _, bad := range []string{
"yesterday",
@@ -400,7 +425,7 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -426,7 +451,7 @@ func TestActivityReport_RejectsUnknownType(t *testing.T) {
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -478,7 +503,7 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -527,7 +552,7 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -593,7 +618,7 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
// only if the handler unexpectedly queries.
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -647,7 +672,7 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
WillReturnError(fmt.Errorf("simulated db hiccup"))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -44,7 +44,6 @@ import (
"log"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
@@ -77,14 +76,12 @@ type AgentMessageAttachment struct {
type AgentMessageWriter struct {
db *sql.DB
broadcaster events.EventEmitter
notifier *push.Notifier
}
// NewAgentMessageWriter binds the writer to the platform's DB pool +
// WebSocket broadcaster. notifier may be nil if push notifications are
// not configured.
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter, notifier *push.Notifier) *AgentMessageWriter {
return &AgentMessageWriter{db: db, broadcaster: broadcaster, notifier: notifier}
// WebSocket broadcaster.
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter) *AgentMessageWriter {
return &AgentMessageWriter{db: db, broadcaster: broadcaster}
}
// Send delivers a single agent → user message. Look up + broadcast +
@@ -135,12 +132,7 @@ func (w *AgentMessageWriter) Send(
}
w.broadcaster.BroadcastOnly(workspaceID, string(events.EventAgentMessage), broadcastPayload)
// 3. Send push notifications to mobile devices.
if w.notifier != nil {
w.notifier.NotifyAgentMessage(ctx, workspaceID, wsName, message)
}
// 4. Persist for chat-history hydration. response_body shape MUST stay
// 3. Persist for chat-history hydration. response_body shape MUST stay
// in sync with extractResponseText + extractFilesFromTask in
// canvas/src/components/tabs/chat/historyHydration.ts:
// - extractResponseText reads body.result (string) → renders text
@@ -86,7 +86,7 @@ func (c *capturingEmitter) RecordAndBroadcast(_ context.Context, eventType strin
// path: workspace lookup, broadcast, INSERT, return nil.
func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-1").
@@ -114,7 +114,7 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
// Drift here = chips disappear on chat reload.
func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-att").
@@ -171,7 +171,7 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter, nil)
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-missing").
@@ -200,7 +200,7 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
// broadcast.
func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-dbfail").
@@ -221,7 +221,7 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
// table doesn't carry multi-KB summaries that bloat list queries.
func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-trunc").
@@ -261,7 +261,7 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter, nil)
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-bc").
@@ -312,7 +312,7 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
// real incidents in alerting.
func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
transientErr := errors.New("connection refused")
mock.ExpectQuery("SELECT name FROM workspaces").
@@ -344,7 +344,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
// coverage. Now it does.
func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
// 200-rune CJK message — exceeds the 80-rune cap, would have hit
// the byte-slice bug.
@@ -393,7 +393,7 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter, nil)
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-noatt").
@@ -543,6 +543,33 @@ func TestDelegationRecord_RejectsInvalidUUID(t *testing.T) {
}
}
func TestDelegationRecord_DBInsertFails(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
h := NewDelegationHandler(wh, broadcaster)
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnError(fmt.Errorf("connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
body := `{"target_id":"550e8400-e29b-41d4-a716-446655440001","task":"hello","delegation_id":"del-xyz"}`
c.Request = httptest.NewRequest("POST", "/delegations/record", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Record(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on DB insert failure, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestDelegationUpdateStatus_CompletedInsertsResultRow(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -225,7 +225,7 @@ const externalUniversalMcpTemplate = `# Universal MCP — standalone register +
# python3.12 / etc.) or use a 3.11+ venv.
# 1. Install the workspace runtime wheel:
pip install "molecule-ai-workspace-runtime>=0.1.999"
pip install molecule-ai-workspace-runtime
# 2. Wire molecule-mcp into your agent's MCP config. Claude Code:
claude mcp add molecule -s user -- env \
@@ -336,7 +336,7 @@ const externalHermesChannelTemplate = `# Hermes channel — bridges this workspa
# also supported via the plugin's dual-mode fallback.
#
# 1. Install the runtime + plugin:
pip install "molecule-ai-workspace-runtime>=0.1.999"
pip install molecule-ai-workspace-runtime
pip install 'git+https://git.moleculesai.app/molecule-ai/hermes-channel-molecule.git'
# 2. Export the workspace credentials:
@@ -407,7 +407,7 @@ const externalCodexTemplate = `# Codex external setup — outbound tools (MCP) +
# 1. Install codex CLI, the workspace runtime, and the bridge daemon:
npm install -g @openai/codex@latest
pip install "molecule-ai-workspace-runtime>=0.1.999"
pip install molecule-ai-workspace-runtime
pip install codex-channel-molecule
# 2. Wire the molecule MCP server into codex's config.toml — this is
@@ -499,7 +499,7 @@ const externalKimiTemplate = `# Kimi CLI external setup — register + heartbeat
# No public URL needed; runs behind NAT in poll mode.
# 1. Install the workspace runtime wheel (provides HTTP client):
pip install "molecule-ai-workspace-runtime>=0.1.999"
pip install molecule-ai-workspace-runtime
# 2. Save credentials and the bridge script:
mkdir -p ~/.molecule-ai/kimi-workspace
@@ -646,6 +646,10 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path.
# external machine today, pair with the Python SDK tab.
# 1. Install openclaw CLI + the workspace runtime wheel:
# The version pin (>=0.1.999) ensures the "molecule-mcp" console
# script is present — it is what keeps the workspace ALIVE on canvas
# (register-on-startup + 20s heartbeat). Older versions only ship
# a2a_mcp_server which does not heartbeat.
npm install -g openclaw@latest
pip install "molecule-ai-workspace-runtime>=0.1.999"
@@ -646,7 +646,7 @@ func TestActivityHandler_List(t *testing.T) {
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -695,7 +695,7 @@ func TestActivityHandler_ListByType(t *testing.T) {
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -723,7 +723,7 @@ func TestActivityHandler_Report(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Expect the INSERT into activity_logs
mock.ExpectExec("INSERT INTO activity_logs").
@@ -752,7 +752,7 @@ func TestActivityHandler_Report_InvalidType(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -980,7 +980,7 @@ func TestActivityHandler_ListEmpty(t *testing.T) {
WillReturnRows(sqlmock.NewRows(columns))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1014,7 +1014,7 @@ func TestActivityHandler_ListCustomLimit(t *testing.T) {
WillReturnRows(sqlmock.NewRows(columns))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1047,7 +1047,7 @@ func TestActivityHandler_ListMaxLimit(t *testing.T) {
WillReturnRows(sqlmock.NewRows(columns))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1075,7 +1075,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -1106,7 +1106,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
func TestActivityHandler_ReportMissingBody(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1179,7 +1179,7 @@ func TestActivityHandler_Report_SourceIDSpoofRejected(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1202,7 +1202,7 @@ func TestActivityHandler_Report_MatchingSourceIDAccepted(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -1232,7 +1232,7 @@ func TestActivityHandler_Report_SourceIDLogInjection(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
+2 -5
View File
@@ -34,7 +34,6 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/gin-gonic/gin"
)
@@ -85,7 +84,6 @@ type mcpTool struct {
type MCPHandler struct {
database *sql.DB
broadcaster *events.Broadcaster
notifier *push.Notifier
// memv2 is the v2 memory plugin wiring (RFC #2728). nil-safe:
// every v2 tool calls memoryV2Available() first and returns a
@@ -96,9 +94,8 @@ type MCPHandler struct {
// NewMCPHandler wires the handler to db and broadcaster.
// Pass db.DB and the platform broadcaster at router-setup time.
// notifier may be nil if push notifications are not configured.
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster, notifier *push.Notifier) *MCPHandler {
return &MCPHandler{database: database, broadcaster: broadcaster, notifier: notifier}
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandler {
return &MCPHandler{database: database, broadcaster: broadcaster}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -26,7 +26,7 @@ import (
func newMCPHandler(t *testing.T) (*MCPHandler, sqlmock.Sqlmock) {
t.Helper()
mock := setupTestDB(t)
h := NewMCPHandler(db.DB, newTestBroadcaster(), nil)
h := NewMCPHandler(db.DB, newTestBroadcaster())
return h, mock
}
@@ -392,7 +392,7 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
// (the tool args don't accept them); pass nil. If a future tool
// schema adds an attachments arg, build []AgentMessageAttachment
// and pass through.
writer := NewAgentMessageWriter(h.database, h.broadcaster, h.notifier)
writer := NewAgentMessageWriter(h.database, h.broadcaster)
if err := writer.Send(ctx, workspaceID, message, nil); err != nil {
if errors.Is(err, ErrWorkspaceNotFound) {
return "", fmt.Errorf("workspace not found")
@@ -287,7 +287,7 @@ func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
if ai <= 0 || zi <= 0 || mi <= 0 {
t.Fatalf("could not locate all keys in output: %s", out)
}
if !(ai < mi && mi < zi) {
if ai >= mi || mi >= zi {
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
}
}
@@ -15,6 +15,7 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"gopkg.in/yaml.v3"
)
// logProvisionPanic is the deferred recover at the top of every provision
@@ -472,9 +473,10 @@ func configDirName(workspaceID string) string {
// runtime means bumping both this list and the Docker image tags.
// knownRuntimes is populated from manifest.json at service init (see
// runtime_registry.go). The package init order is:
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
//
// The fallback matters for unit tests that don't mount the manifest.
//
// "external" is a first-class runtime that intentionally does NOT
@@ -539,6 +541,9 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
}
// Sanitize name/role/model for YAML safety — always double-quote so
// a crafted value with a newline or colon can't terminate the scalar
@@ -554,6 +559,11 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
quoteModel := yamlQuote(model)
configYAML := fmt.Sprintf("name: %s\ndescription: %s\nversion: 1.0.0\ntier: %d\nruntime: %s\n",
quoteName, quoteRole, payload.Tier, runtime)
if runtime == "claude-code" {
if providersYAML := h.defaultTemplateProvidersYAML(runtime); providersYAML != "" {
configYAML += providersYAML + "\n"
}
}
// Model always at top level — config.py reads raw["model"] for all runtimes.
configYAML += fmt.Sprintf("model: %s\n", quoteModel)
@@ -563,7 +573,11 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// and preflight already validates that the env vars are present before
// the agent loop starts. Hardcoding token names here caused #1028
// (expired CLAUDE_CODE_OAUTH_TOKEN baked into config.yaml).
configYAML += "runtime_config:\n timeout: 0\n"
configYAML += "runtime_config:\n"
if runtime == "claude-code" {
configYAML += fmt.Sprintf(" model: %s\n", quoteModel)
}
configYAML += " timeout: 0\n"
files["config.yaml"] = []byte(configYAML)
@@ -571,6 +585,60 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
return files
}
func normalizeClaudeCodeModel(model string) string {
model = strings.TrimSpace(model)
if before, after, ok := strings.Cut(model, "/"); ok && before != "" && after != "" {
return after
}
return model
}
func (h *WorkspaceHandler) defaultTemplateProvidersYAML(runtime string) string {
if h.configsDir == "" {
return ""
}
templateName := runtime + "-default"
templatePath, err := resolveInsideRoot(h.configsDir, templateName)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: %v", runtime, err)
return ""
}
data, err := os.ReadFile(filepath.Join(templatePath, "config.yaml"))
if err != nil {
return ""
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: invalid YAML: %v", runtime, err)
return ""
}
if len(root.Content) == 0 || root.Content[0].Kind != yaml.MappingNode {
return ""
}
mapping := root.Content[0]
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value != "providers" {
continue
}
out := yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "providers"},
mapping.Content[i+1],
},
}
encoded, err := yaml.Marshal(&out)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: marshal failed: %v", runtime, err)
return ""
}
return strings.TrimRight(string(encoded), "\n")
}
return ""
}
// deriveProviderFromModelSlug maps a hermes-agent model slug prefix to
// its provider name — a Go translation of the case statement in
// workspace-configs-templates/hermes/scripts/derive-provider.sh that we
@@ -261,6 +261,67 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
}
}
func TestEnsureDefaultConfig_ClaudeCodeCopiesProviderRegistry(t *testing.T) {
broadcaster := newTestBroadcaster()
configsDir := t.TempDir()
templateDir := filepath.Join(configsDir, "claude-code-default")
if err := os.MkdirAll(templateDir, 0o755); err != nil {
t.Fatalf("mkdir template: %v", err)
}
if err := os.WriteFile(filepath.Join(templateDir, "config.yaml"), []byte(`
name: Claude Code Agent
runtime: claude-code
providers:
- name: anthropic-oauth
auth_mode: oauth
model_aliases: [sonnet]
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
- name: minimax
auth_mode: third_party_anthropic_compat
model_prefixes: [minimax-]
base_url: https://api.minimax.io/anthropic
auth_env: [MINIMAX_API_KEY, ANTHROPIC_AUTH_TOKEN]
runtime_config:
model: sonnet
`), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
files := handler.ensureDefaultConfig("ws-code-123", models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 4,
Runtime: "claude-code",
Model: "minimax/MiniMax-M2.7",
})
var parsed struct {
Model string `yaml:"model"`
Providers []struct {
Name string `yaml:"name"`
ModelPrefixes []string `yaml:"model_prefixes"`
} `yaml:"providers"`
RuntimeConfig struct {
Model string `yaml:"model"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(files["config.yaml"], &parsed); err != nil {
t.Fatalf("generated YAML invalid: %v\n%s", err, files["config.yaml"])
}
if parsed.Model != "MiniMax-M2.7" {
t.Fatalf("top-level model = %q, want MiniMax-M2.7\n%s", parsed.Model, files["config.yaml"])
}
if parsed.RuntimeConfig.Model != "MiniMax-M2.7" {
t.Fatalf("runtime_config.model = %q, want MiniMax-M2.7\n%s", parsed.RuntimeConfig.Model, files["config.yaml"])
}
if len(parsed.Providers) != 2 {
t.Fatalf("providers len = %d, want 2\n%s", len(parsed.Providers), files["config.yaml"])
}
if parsed.Providers[1].Name != "minimax" || len(parsed.Providers[1].ModelPrefixes) != 1 || parsed.Providers[1].ModelPrefixes[0] != "minimax-" {
t.Fatalf("minimax provider registry not preserved: %+v\n%s", parsed.Providers, files["config.yaml"])
}
}
func TestEnsureDefaultConfig_CustomModel(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@@ -207,7 +207,7 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
resolver := namespace.New(db)
// MCPHandler needs a real *sql.DB; pass the sqlmock-backed one.
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
return h, plugin, mock
}
@@ -430,7 +430,7 @@ func TestE2E_PluginUnreachable_AgentSeesClearError(t *testing.T) {
db, _, _ := sqlmock.New()
defer db.Close()
resolver := namespace.New(db)
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
_, err := h.Dispatch(context.Background(), "root-1", "commit_memory_v2", map[string]interface{}{
"content": "x",
-75
View File
@@ -1,75 +0,0 @@
package push
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Handler exposes HTTP endpoints for push-token management.
type Handler struct {
repo *Repo
}
// NewHandler creates a push-token HTTP handler.
func NewHandler(repo *Repo) *Handler {
return &Handler{repo: repo}
}
// RegisterRoutes mounts push-token routes on the given router group.
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/push-tokens", h.Create)
rg.DELETE("/push-tokens", h.Delete)
}
// Create handles POST /push-tokens.
// Body: { "token": "ExponentPushToken[xxx]", "platform": "ios" | "android" }
func (h *Handler) Create(c *gin.Context) {
workspaceID := c.Param("id")
if _, err := uuid.Parse(workspaceID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
var body struct {
Token string `json:"token" binding:"required"`
Platform string `json:"platform" binding:"required,oneof=ios android"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.repo.SaveToken(c.Request.Context(), workspaceID, body.Token, body.Platform); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
return
}
c.Status(http.StatusNoContent)
}
// Delete handles DELETE /push-tokens.
// Body: { "token": "ExponentPushToken[xxx]" }
func (h *Handler) Delete(c *gin.Context) {
workspaceID := c.Param("id")
if _, err := uuid.Parse(workspaceID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
var body struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.repo.DeleteToken(c.Request.Context(), workspaceID, body.Token); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete token"})
return
}
c.Status(http.StatusNoContent)
}
-102
View File
@@ -1,102 +0,0 @@
package push
import (
"context"
"database/sql"
"log"
"os"
"time"
)
// Notifier sends push notifications for agent messages.
type Notifier struct {
repo *Repo
sender *Sender
}
// NewNotifier creates a Notifier.
func NewNotifier(db *sql.DB, sender *Sender) *Notifier {
return &Notifier{
repo: NewRepo(db),
sender: sender,
}
}
// NotifyAgentMessage sends a push notification to all registered devices for a
// workspace when an agent sends a message. It runs asynchronously (fire-and-
// forget) so the caller's WebSocket broadcast is never blocked.
func (n *Notifier) NotifyAgentMessage(ctx context.Context, workspaceID, workspaceName, message string) {
if n == nil || n.sender == nil {
return
}
// Capture values for the goroutine.
wsID := workspaceID
wsName := workspaceName
msg := message
go func() {
// Use a fresh context with timeout so a slow Expo API doesn't
// leak the caller's context deadline.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
tokens, err := n.repo.GetTokens(ctx, wsID)
if err != nil {
log.Printf("push: failed to get tokens for workspace %s: %v", wsID, err)
return
}
if len(tokens) == 0 {
return
}
// Expo accepts batches of up to ~100 messages; we cap lower to stay
// well under the limit.
const batchSize = 50
for i := 0; i < len(tokens); i += batchSize {
end := i + batchSize
if end > len(tokens) {
end = len(tokens)
}
batch := tokens[i:end]
messages := make([]Message, 0, len(batch))
for _, t := range batch {
messages = append(messages, Message{
To: t.Token,
Title: wsName,
Body: truncate(msg, 100),
Data: map[string]string{
"type": "agent_message",
"workspaceId": wsID,
"workspaceSlug": os.Getenv("MOLECULE_ORG_SLUG"),
},
Sound: "default",
Priority: "high",
})
}
results, err := n.sender.Send(ctx, messages)
if err != nil {
log.Printf("push: send failed for workspace %s: %v", wsID, err)
continue
}
// Remove invalid tokens.
for j, r := range results {
if ShouldRemoveToken(r) {
if delErr := n.repo.DeleteToken(ctx, wsID, batch[j].Token); delErr != nil {
log.Printf("push: failed to delete invalid token for workspace %s: %v", wsID, delErr)
}
}
}
}
}()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
-159
View File
@@ -1,159 +0,0 @@
package push
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSenderSend(t *testing.T) {
gin.SetMode(gin.TestMode)
expoResponse := map[string]interface{}{
"data": []map[string]interface{}{
{"status": "ok", "id": "abc123"},
{"status": "error", "message": "Invalid token", "details": map[string]string{"error": "DeviceNotRegistered"}},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
var msgs []Message
require.NoError(t, json.NewDecoder(r.Body).Decode(&msgs))
assert.Len(t, msgs, 2)
assert.Equal(t, "ExponentPushToken[test1]", msgs[0].To)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(expoResponse)
}))
defer server.Close()
sender := NewSender("")
sender.apiURL = server.URL
results, err := sender.Send(context.Background(), []Message{
{To: "ExponentPushToken[test1]", Title: "Test", Body: "Hello"},
{To: "ExponentPushToken[test2]", Title: "Test", Body: "World"},
})
require.NoError(t, err)
require.Len(t, results, 2)
assert.Equal(t, "ok", results[0].Status)
assert.Equal(t, "error", results[1].Status)
assert.True(t, ShouldRemoveToken(results[1]))
}
func TestSenderSendEmpty(t *testing.T) {
sender := NewSender("")
results, err := sender.Send(context.Background(), nil)
require.NoError(t, err)
assert.Nil(t, results)
}
func TestHandlerCreate(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectExec("INSERT INTO push_tokens").
WithArgs("11111111-1111-1111-1111-111111111111", "ExponentPushToken[abc]", "ios").
WillReturnResult(sqlmock.NewResult(1, 1))
repo := NewRepo(db)
handler := NewHandler(repo)
router := gin.New()
group := router.Group("/workspaces/:id")
handler.RegisterRoutes(group)
w := httptest.NewRecorder()
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestHandlerCreateInvalidPlatform(t *testing.T) {
gin.SetMode(gin.TestMode)
db, _, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
handler := NewHandler(NewRepo(db))
router := gin.New()
group := router.Group("/workspaces/:id")
handler.RegisterRoutes(group)
w := httptest.NewRecorder()
body := `{"token":"ExponentPushToken[abc]","platform":"windows"}`
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestHandlerDelete(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectExec("DELETE FROM push_tokens").
WithArgs("22222222-2222-2222-2222-222222222222", "ExponentPushToken[del]").
WillReturnResult(sqlmock.NewResult(0, 1))
repo := NewRepo(db)
handler := NewHandler(repo)
router := gin.New()
group := router.Group("/workspaces/:id")
handler.RegisterRoutes(group)
w := httptest.NewRecorder()
body := `{"token":"ExponentPushToken[del]"}`
req, _ := http.NewRequest("DELETE", "/workspaces/22222222-2222-2222-2222-222222222222/push-tokens", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestRepoGetTokens(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token", "platform", "created_at"}).
AddRow("1", "ws-1", "ExponentPushToken[a]", "ios", "2026-01-01T00:00:00Z").
AddRow("2", "ws-1", "ExponentPushToken[b]", "android", "2026-01-01T00:00:00Z"))
repo := NewRepo(db)
tokens, err := repo.GetTokens(context.Background(), "ws-1")
require.NoError(t, err)
require.Len(t, tokens, 2)
assert.Equal(t, "ExponentPushToken[a]", tokens[0].Token)
assert.Equal(t, "ios", tokens[0].Platform)
assert.Equal(t, "ExponentPushToken[b]", tokens[1].Token)
require.NoError(t, mock.ExpectationsWereMet())
}
-76
View File
@@ -1,76 +0,0 @@
package push
import (
"context"
"database/sql"
"fmt"
)
// Token is one registered push token for a workspace.
type Token struct {
ID string
WorkspaceID string
Token string
Platform string
CreatedAt string
}
// Repo reads and writes push tokens in Postgres.
type Repo struct {
db *sql.DB
}
// NewRepo creates a token repository backed by db.
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
// SaveToken registers a push token for a workspace. If the same token already
// exists for the workspace, it updates the timestamp.
func (r *Repo) SaveToken(ctx context.Context, workspaceID, token, platform string) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO push_tokens (workspace_id, token, platform)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, token) DO UPDATE
SET updated_at = now()
`, workspaceID, token, platform)
if err != nil {
return fmt.Errorf("push_tokens: save: %w", err)
}
return nil
}
// DeleteToken removes a push token. Returns nil even if the token did not exist.
func (r *Repo) DeleteToken(ctx context.Context, workspaceID, token string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM push_tokens
WHERE workspace_id = $1 AND token = $2
`, workspaceID, token)
if err != nil {
return fmt.Errorf("push_tokens: delete: %w", err)
}
return nil
}
// GetTokens returns all active push tokens for a workspace.
func (r *Repo) GetTokens(ctx context.Context, workspaceID string) ([]Token, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, workspace_id, token, platform, created_at
FROM push_tokens
WHERE workspace_id = $1
`, workspaceID)
if err != nil {
return nil, fmt.Errorf("push_tokens: list: %w", err)
}
defer rows.Close()
var tokens []Token
for rows.Next() {
var t Token
if err := rows.Scan(&t.ID, &t.WorkspaceID, &t.Token, &t.Platform, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("push_tokens: scan: %w", err)
}
tokens = append(tokens, t)
}
return tokens, rows.Err()
}
-104
View File
@@ -1,104 +0,0 @@
package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const expoPushAPI = "https://exp.host/--/api/v2/push/send"
// Message is one Expo push notification.
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]string `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
Priority string `json:"priority,omitempty"`
}
// Sender delivers push notifications via the Expo Push Service.
type Sender struct {
apiURL string
httpClient *http.Client
expoToken string // optional Expo access token for authenticated requests
}
// NewSender creates a Sender. expoToken may be empty for unauthenticated
// requests (sufficient for most use cases).
func NewSender(expoToken string) *Sender {
return &Sender{
apiURL: expoPushAPI,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
expoToken: expoToken,
}
}
// SendResult is the per-recipient status from Expo.
type SendResult struct {
Status string `json:"status"`
ID string `json:"id"`
Message string `json:"message,omitempty"`
Details struct {
Error string `json:"error,omitempty"`
} `json:"details,omitempty"`
}
// expoResponse is the wrapper shape returned by the Expo API.
type expoResponse struct {
Data []SendResult `json:"data"`
}
// Send fires a batch of push messages. It returns a slice of results in the
// same order as the input, plus an error only when the HTTP call itself fails.
// Callers should inspect each result's Status field for per-message errors
// (e.g. "DeviceNotRegistered" → token should be deleted).
func (s *Sender) Send(ctx context.Context, messages []Message) ([]SendResult, error) {
if len(messages) == 0 {
return nil, nil
}
body, err := json.Marshal(messages)
if err != nil {
return nil, fmt.Errorf("push: marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("push: new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
if s.expoToken != "" {
req.Header.Set("Authorization", "Bearer "+s.expoToken)
}
res, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("push: post: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("push: expo returned %d", res.StatusCode)
}
var resp expoResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("push: decode: %w", err)
}
return resp.Data, nil
}
// ShouldRemoveToken reports whether a SendResult indicates the token is no
// longer valid and should be deleted from the database.
func ShouldRemoveToken(r SendResult) bool {
return r.Status == "error" && r.Details.Error == "DeviceNotRegistered"
}
+2 -15
View File
@@ -20,7 +20,6 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
"github.com/docker/docker/client"
@@ -319,25 +318,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// Remaining auth-gated workspace sub-routes — appended to wsAuth group declared above.
{
// Push notifications (mobile)
var pushNotifier *push.Notifier
if expoToken := os.Getenv("EXPO_ACCESS_TOKEN"); expoToken != "" {
pushNotifier = push.NewNotifier(db.DB, push.NewSender(expoToken))
}
// Activity Logs
acth := handlers.NewActivityHandler(broadcaster, pushNotifier)
acth := handlers.NewActivityHandler(broadcaster)
wsAuth.GET("/activity", acth.List)
wsAuth.GET("/session-search", acth.SessionSearch)
wsAuth.POST("/activity", acth.Report)
wsAuth.POST("/notify", acth.Notify)
// Push token registration (mobile)
if pushNotifier != nil {
pushH := push.NewHandler(push.NewRepo(db.DB))
pushH.RegisterRoutes(wsAuth)
}
// Chat history — RFC #2945 PR-C (issue #3017) + PR-D (issue
// #3026). Server-side rendering of activity_logs rows into
// the canonical ChatMessage shape; storage is plugin-shaped
@@ -441,7 +428,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// opencode session cannot saturate the platform.
// C3: commit_memory/recall_memory with scope=GLOBAL → permission error;
// send_message_to_user excluded unless MOLECULE_MCP_ALLOW_SEND_MESSAGE=true.
mcpH := handlers.NewMCPHandler(db.DB, broadcaster, pushNotifier)
mcpH := handlers.NewMCPHandler(db.DB, broadcaster)
if memBundle != nil {
mcpH.WithMemoryV2(memBundle.Plugin, memBundle.Resolver)
}
@@ -1 +0,0 @@
DROP TABLE IF EXISTS push_tokens;
@@ -1,11 +0,0 @@
CREATE TABLE push_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
token TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(workspace_id, token)
);
CREATE INDEX idx_push_tokens_workspace ON push_tokens(workspace_id);