forked from molecule-ai/molecule-core
Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9d2786f45 | |||
| 068c968206 | |||
| 97c042f666 | |||
| 3d6303afcc | |||
| 3fcaa1fcc5 | |||
| 6c823cf673 | |||
| 4193d54852 | |||
| 0bcf195fbc | |||
| 8885f7cd12 | |||
| cdbf28fd76 | |||
| 4b82db72a7 | |||
| 07bd91e436 | |||
| ed0874504e | |||
| e39fc92074 | |||
| 1819ac21f4 | |||
| d84d88ad70 | |||
| 6bb272360d | |||
| 1f1ead1833 | |||
| c5f40de585 | |||
| 330a5842ab | |||
| 2505b36a2c | |||
| e0feae18f4 | |||
| 502aa082bc | |||
| 8f732511b1 | |||
| 7d0df65474 | |||
| 33327cf077 | |||
| fa27611e9c | |||
| 865a366573 | |||
| b73f599184 | |||
| 5855be50b4 | |||
| e766061800 | |||
| ca644134f2 | |||
| e909417224 | |||
| 9bb4bbdff7 | |||
| bec1cb3786 | |||
| 1d6b09f2bd | |||
| 0be89053e8 | |||
| d81fb98163 | |||
| 4d5c9a6646 | |||
| 9ecee78782 | |||
| 141dfdae52 | |||
| d21c09babe | |||
| 2b3a8f2e4d | |||
| 9eb530bbf0 | |||
| 62e793040e | |||
| 6946cd12c5 | |||
| e43bd7ceb0 | |||
| 85140f1c72 | |||
| 5b3ce5c818 | |||
| bcc72419ce | |||
| e4e1bf4080 | |||
| 62629eda4a | |||
| 050cb035d6 | |||
| e075557b19 | |||
| fab65c78d6 | |||
| 0cef033a6a | |||
| b83b533381 | |||
| a23cf6a6bb | |||
| 6acd63fa5a | |||
| bfc393c065 | |||
| c0f4c16cc9 | |||
| 7194b08987 | |||
| d9e380c5bc | |||
| f8a238dfdd | |||
| 830de70e84 | |||
| 3f68ac1fcb | |||
| 5efa92fbc6 | |||
| f0664264cb | |||
| 7b194eb1aa | |||
| 6235ef7461 | |||
| 5b7b669b4c | |||
| 9dda84d671 | |||
| 7c6acc18ae | |||
| 1e1f4d635b | |||
| 3a00dd236f | |||
| 229b1a902a | |||
| 0276b295cc | |||
| 194cdf012b | |||
| 6b30ab6391 | |||
| f0e8d9bb23 | |||
| ee56443146 | |||
| 43e2d24c5b | |||
| 0b840df563 | |||
| bee4f9ea79 | |||
| c1e32ff4a7 | |||
| bac04dc278 | |||
| e16d7eaa08 | |||
| 17f1f30b3f | |||
| 694c05552b | |||
| 948b5a0d89 | |||
| a6d67b4c68 | |||
| d2da0c8d34 | |||
| be5fbb5ad3 | |||
| b9ca4ad84a | |||
| b73d3bfff2 | |||
| 51ea86e3ec | |||
| d64641904f | |||
| 70104d1cef | |||
| a37a4a6e40 | |||
| 85b09659e6 | |||
| 6de3c1ccd2 | |||
| d4256b9d83 | |||
| 8313b2a7a7 | |||
| 566c095571 | |||
| 694a036a7f | |||
| 8c1dbc6ba5 | |||
| 72d0d4b44e | |||
| 52e61d4704 | |||
| 10e510f50c | |||
| 6fac24e3de | |||
| f51722411b | |||
| f0015bff81 | |||
| b72d1d3f26 | |||
| a674a6547e | |||
| f2f5338183 | |||
| e01077be38 | |||
| c1de2287fd | |||
| f3187ea0c1 | |||
| f92ba492de | |||
| 00cfe51df7 | |||
| 55ef3176ed | |||
| 4b074f631b | |||
| 50c3bdfd6c | |||
| a33c879017 | |||
| e91186c4bf | |||
| 089be695a9 | |||
| dcc870a6b7 | |||
| d144dcc700 | |||
| 656a02fae4 | |||
| c53155ec5f | |||
| debe29c889 | |||
| 7a39a08837 | |||
| bb9bf85dbd | |||
| ff21bbb876 | |||
| da3cb4c098 | |||
| ef9bd1e0e2 | |||
| b759548822 | |||
| cce2050b6a | |||
| e87df906bd | |||
| c60e2b5fa2 | |||
| 143fbb91ff | |||
| 1b29b24e83 | |||
| 6033179f48 | |||
| ab1acff2d2 | |||
| 19df43e3da | |||
| dcece2762b | |||
| 57bfa40990 | |||
| d88fbb90fb | |||
| 2e6bed71b9 | |||
| 030377bb84 | |||
| f93957e982 | |||
| b530c147de | |||
| f39b595a9c | |||
| 95fdf86187 | |||
| 04f7a07add | |||
| 3dfeb180ab | |||
| 88ff0d770b | |||
| 86b8d8d744 | |||
| 9b9419ad5e | |||
| a19ee90556 | |||
| bd0580f4af | |||
| 64e58fb390 | |||
| 9ceda9d81f | |||
| b6310d7ebf | |||
| d75b73e713 | |||
| 0886dbc923 | |||
| 7420631c32 | |||
| caf19e8980 | |||
| 38bc27df0d | |||
| 6748035720 | |||
| c74d0ecc94 | |||
| 9dd29882e2 | |||
| e342d0c5a7 | |||
| 166ad20cd7 | |||
| 4a2dda7cac | |||
| 8b9f809966 | |||
| a869bc1536 | |||
| d3e115cb06 | |||
| b372c265ab | |||
| 146c0e7c60 | |||
| 5d8b5e96e3 | |||
| dc6e1ac2bf | |||
| c2e12f3fb6 | |||
| dd5df70e59 | |||
| f1dc721eeb | |||
| 5b78bea10d | |||
| a5903af459 | |||
| 07d09f3696 | |||
| f7c270bf24 | |||
| 0301f90183 | |||
| feef80423b | |||
| 469b24ff8f | |||
| c4d3c9a451 | |||
| 2652ea8342 | |||
| 1e01083e55 | |||
| eab36e217e | |||
| 7ee696ec9a | |||
| decec9b9a1 | |||
| ada27fdb5d | |||
| f0f4d0e761 | |||
| f01f374072 | |||
| d99b3f2aec |
@@ -154,30 +154,71 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Upstream is publish-workspace-server-image. Check E2E state.
|
||||
# The jq filter must defend against TWO empty cases that gh
|
||||
# CLI emits indistinguishably:
|
||||
# 1. gh exits non-zero (network blip, auth issue) → handled
|
||||
# by the `|| echo "none/none"` fallback below.
|
||||
# 2. gh exits zero but returns `[]` (no E2E run on this
|
||||
# main SHA — the common case for canvas-only / cmd-only
|
||||
# / sweep-only changes whose paths don't trigger E2E).
|
||||
# Without `(.[0] // {})`, jq sees `null` and emits
|
||||
# "null/none" — which the case statement below has no
|
||||
# branch for, so it falls into *) → exit 1.
|
||||
# Surfaced 2026-04-30 the first time the App-token chain
|
||||
# (#2389) actually fired auto-promote-on-e2e from a publish
|
||||
# upstream — every prior run was E2E-upstream which
|
||||
# short-circuits before this gate.
|
||||
RESULT=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--workflow e2e-staging-saas.yml \
|
||||
--branch main \
|
||||
--commit "$SHA" \
|
||||
--limit 1 \
|
||||
--json status,conclusion \
|
||||
--jq '(.[0] // {}) | "\(.status // "none")/\(.conclusion // "none")"' \
|
||||
2>/dev/null || echo "none/none")
|
||||
# Upstream is publish-workspace-server-image. Check E2E state
|
||||
# for the same SHA via Gitea's commit-status API.
|
||||
#
|
||||
# GitHub-era this was `gh run list --workflow=X --commit=SHA
|
||||
# --json status,conclusion` returning either `[]` (no run on
|
||||
# this SHA) or `[{status, conclusion}]` (the run's state).
|
||||
# Gitea has NO workflow-runs API at all — `/api/v1/repos/.../
|
||||
# actions/runs` returns 404 (verified 2026-05-07, issue #75).
|
||||
# However Gitea Actions DOES emit a commit status per workflow
|
||||
# job, with `context = "<Workflow Name> / <Job Name> (<event>)"`,
|
||||
# which is exactly what we need: each E2E run leg becomes one
|
||||
# status row on the SHA, and the aggregate state encodes the
|
||||
# run's outcome.
|
||||
#
|
||||
# Mapping:
|
||||
# 0 matched contexts → "none/none" (E2E paths-
|
||||
# filtered
|
||||
# out — same
|
||||
# semantic
|
||||
# as before)
|
||||
# any context = pending → "in_progress/none" (defer)
|
||||
# any context = error|failure → "completed/failure" (abort)
|
||||
# all contexts = success → "completed/success" (proceed)
|
||||
#
|
||||
# The "completed/cancelled" and "completed/timed_out" buckets
|
||||
# don't have direct Gitea analogs (Gitea statuses are
|
||||
# success / failure / error / pending / warning). Per-SHA
|
||||
# concurrency cancellation surfaces as `error` on Gitea, which
|
||||
# we map to "completed/failure" rather than "completed/cancelled"
|
||||
# — losing the soft-defer semantic of the cancelled bucket on
|
||||
# this fleet. Tradeoff: the staleness alarm (auto-promote-stale-
|
||||
# alarm.yml) still catches a stuck :latest within 4h, and a
|
||||
# legitimate cancel is rare enough that aborting + manual
|
||||
# re-dispatch is acceptable. If we measure cancel frequency
|
||||
# > 1/week, revisit by reading the run-step-summary text via
|
||||
# a follow-up script.
|
||||
#
|
||||
# Network or auth blips collapse to "none/none" via the curl
|
||||
# `|| true` fallback, matching the pre-Gitea behaviour where
|
||||
# an empty list also degenerated to none/none.
|
||||
GITEA_API_URL="${GITHUB_SERVER_URL:-https://git.moleculesai.app}/api/v1"
|
||||
STATUSES_JSON=$(curl --fail-with-body -sS \
|
||||
-H "Authorization: token ${GH_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_API_URL}/repos/${REPO}/commits/${SHA}/statuses?limit=100" \
|
||||
2>/dev/null || echo "[]")
|
||||
RESULT=$(printf '%s' "$STATUSES_JSON" | jq -r '
|
||||
# Filter to E2E Staging SaaS (full lifecycle) statuses.
|
||||
# Match by leading workflow-name prefix so the "<job>
|
||||
# (<event>)" tail is irrelevant. Gitea emits the workflow
|
||||
# name verbatim from the YAML `name:` field.
|
||||
[.[] | select(.context | startswith("E2E Staging SaaS (full lifecycle) /"))] as $rows
|
||||
| if ($rows | length) == 0 then
|
||||
"none/none"
|
||||
elif any($rows[]; .status == "pending") then
|
||||
"in_progress/none"
|
||||
elif any($rows[]; .status == "failure" or .status == "error") then
|
||||
"completed/failure"
|
||||
elif all($rows[]; .status == "success") then
|
||||
"completed/success"
|
||||
else
|
||||
# Mixed / unknown — fall through to *) bucket below.
|
||||
"completed/" + ($rows[0].status // "unknown")
|
||||
end
|
||||
' 2>/dev/null || echo "none/none")
|
||||
|
||||
echo "E2E Staging SaaS for ${SHA:0:7}: $RESULT"
|
||||
|
||||
@@ -199,16 +240,13 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
completed/cancelled)
|
||||
# cancelled ≠ failure. Per-SHA concurrency cancels older E2E
|
||||
# runs when a newer push lands (memory:
|
||||
# feedback_concurrency_group_per_sha) — the newer SHA will
|
||||
# have its own E2E + promote chain. Treat the same as
|
||||
# in_progress: defer without aborting, let the next E2E run
|
||||
# promote when it lands.
|
||||
#
|
||||
# Caught 2026-05-05 02:03 on sha 31f9a5e — auto-promote
|
||||
# blocked the whole chain because this case fell through to
|
||||
# exit 1 instead of clean defer.
|
||||
# GitHub-era only: cancelled ≠ failure. Gitea statuses
|
||||
# don't expose a "cancelled" state — a per-SHA concurrency
|
||||
# cancellation surfaces as `failure` or `error` on Gitea
|
||||
# and is now handled by the failure branch above. This
|
||||
# arm is kept for backwards compatibility / dual-host
|
||||
# operation (if we ever add a non-Gitea fallback) but
|
||||
# under the post-#75 flow it's unreachable.
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ⏭ Auto-promote deferred — E2E Staging SaaS was cancelled"
|
||||
|
||||
@@ -2,61 +2,148 @@ name: Auto-promote staging → main
|
||||
|
||||
# Fires after any of the staging-branch quality gates complete. When ALL
|
||||
# required gates are green on the same staging SHA, opens (or re-uses)
|
||||
# a PR `staging → main` and enables auto-merge so the merge queue lands
|
||||
# it. Closes the gap that historically let features sit on staging for
|
||||
# weeks waiting for a bulk promotion PR (see molecule-core#1496 for the
|
||||
# 1172-commit example).
|
||||
# a PR `staging → main` and schedules Gitea auto-merge so the PR lands
|
||||
# automatically once approval + status checks are satisfied.
|
||||
#
|
||||
# 2026-04-28 rewrite (PR #142): the previous version did a direct
|
||||
# `git merge --ff-only origin staging && git push origin main`. That
|
||||
# breaks against main's branch-protection ruleset, which requires
|
||||
# status checks "set by the expected GitHub apps" — direct pushes
|
||||
# can't satisfy that condition (only PR merges through the queue can).
|
||||
# The workflow was failing every tick with:
|
||||
# remote: error: GH006: Protected branch update failed for refs/heads/main.
|
||||
# remote: - Required status checks ... were not set by the expected GitHub apps.
|
||||
# Fix: mirror the PR-based pattern from auto-sync-main-to-staging.yml
|
||||
# (the reverse-direction sync, fixed in #2234 for the same reason).
|
||||
# Both directions now use the same merge-queue path that humans use,
|
||||
# no special-case bypass.
|
||||
# ============================================================
|
||||
# What this workflow does
|
||||
# ============================================================
|
||||
#
|
||||
# Safety model:
|
||||
# - Runs ONLY on workflow_run events for the staging branch.
|
||||
# - Requires EVERY named gate workflow to have the same head_sha and
|
||||
# all be `conclusion == success`. If any of them is red, skipped,
|
||||
# cancelled, or pending, we abort (stay on the current main).
|
||||
# - The PR base=main head=staging path lets GitHub itself enforce
|
||||
# branch protection. If main has diverged from staging or required
|
||||
# checks aren't satisfied, the merge queue declines the PR — no
|
||||
# need for a manual ff-only ancestry check here.
|
||||
# - Loop safety: the auto-sync-main-to-staging workflow fires when
|
||||
# main lands the auto-promote PR, but its merge into staging is by
|
||||
# GITHUB_TOKEN which doesn't trigger downstream workflow_run events
|
||||
# (GitHub Actions safety). So this workflow doesn't re-fire from
|
||||
# its own promote landing.
|
||||
# 1. On a workflow_run completion event for one of the staging gate
|
||||
# workflows (CI, E2E Staging Canvas, E2E API Smoke, CodeQL),
|
||||
# checks if the combined status on the staging head SHA is green.
|
||||
# 2. If green, opens (or re-uses) a PR `head: staging → base: main`
|
||||
# via Gitea REST `POST /api/v1/repos/.../pulls`.
|
||||
# 3. Schedules auto-merge via `POST /api/v1/repos/.../pulls/{index}/merge`
|
||||
# with `merge_when_checks_succeed: true`. Gitea waits for the
|
||||
# approval requirement on `main` (`required_approvals: 1`) and
|
||||
# the status-check gates, then merges.
|
||||
# 4. The merge commit lands on `main` and fires
|
||||
# `publish-workspace-server-image.yml` naturally via its
|
||||
# `on: push: branches: [main]` trigger — no explicit dispatch
|
||||
# needed (see "Why no workflow_dispatch tail" below).
|
||||
#
|
||||
# Toggle via repo variable AUTO_PROMOTE_ENABLED (true/unset). When
|
||||
# unset, the workflow logs what it would have done but doesn't open
|
||||
# the PR — useful for dry-running the gate logic without surfacing
|
||||
# a noisy PR while staging CI is still flaky.
|
||||
# `auto-sync-main-to-staging.yml` is the reverse-direction
|
||||
# counterpart (main → staging, fast-forward push). Together they
|
||||
# keep the staging-superset-of-main invariant tight.
|
||||
#
|
||||
# **One-time repo setting (load-bearing):** this workflow opens the
|
||||
# staging→main PR via `gh pr create` using the default GITHUB_TOKEN.
|
||||
# Since GitHub's 2022 default change, that token cannot create or
|
||||
# approve PRs unless the repo opts in. The toggle is at:
|
||||
# ============================================================
|
||||
# Why Gitea REST (and not `gh pr create`)
|
||||
# ============================================================
|
||||
#
|
||||
# Settings → Actions → General → Workflow permissions
|
||||
# → ✅ Allow GitHub Actions to create and approve pull requests
|
||||
# Pre-2026-05-06 this workflow used `gh pr create`, `gh pr merge --auto`,
|
||||
# `gh run list`, and `gh workflow run` against GitHub. After the
|
||||
# GitHub→Gitea cutover those calls fail because:
|
||||
#
|
||||
# Without it, every workflow_run fails with:
|
||||
# - `gh pr create / merge / view / list` route to GitHub GraphQL
|
||||
# (`/api/graphql`). Gitea does not expose a GraphQL endpoint;
|
||||
# every call returns `HTTP 405 Method Not Allowed` — same root
|
||||
# cause as #65 (auto-sync) which PR #66 fixed by dropping `gh`
|
||||
# entirely.
|
||||
# - `gh run list --workflow=...` GitHub-shape; Gitea has the
|
||||
# simpler `GET /repos/.../commits/{ref}/status` combined-status
|
||||
# endpoint instead.
|
||||
# - `gh workflow run X.yml` calls `POST /repos/.../actions/workflows/{id}/dispatches`,
|
||||
# which does NOT exist on Gitea 1.22.6 (verified via swagger.v1.json).
|
||||
#
|
||||
# pull request create failed: GraphQL: GitHub Actions is not
|
||||
# permitted to create or approve pull requests (createPullRequest)
|
||||
# So this workflow uses direct `curl` calls to Gitea REST. No `gh`
|
||||
# CLI dependency, no GraphQL, no missing-endpoint footgun.
|
||||
#
|
||||
# Observed 2026-04-29 01:43 UTC blocking promotion of fcd87b9 (PRs
|
||||
# #2248 + #2249); manually bridged via PR #2252. Re-check this
|
||||
# setting if auto-promote starts failing with createPullRequest
|
||||
# errors after a repo or org admin change.
|
||||
# ============================================================
|
||||
# Why no workflow_dispatch tail (was load-bearing on GitHub, dead on Gitea)
|
||||
# ============================================================
|
||||
#
|
||||
# The GitHub-era version had a 60-line polling step that waited for
|
||||
# the promote PR to merge, then explicitly dispatched
|
||||
# `publish-workspace-server-image.yml` on `--ref main`. That step
|
||||
# existed because GitHub's GITHUB_TOKEN-initiated merges suppress
|
||||
# downstream `on: push` workflows (the documented "no recursion" rule
|
||||
# — https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
|
||||
# The explicit dispatch was the workaround.
|
||||
#
|
||||
# Gitea Actions does NOT have this no-recursion rule. PR #66's auto-
|
||||
# sync merge to main fired `auto-promote-staging` on the next push
|
||||
# trigger naturally. So the cascade fires on the natural push event;
|
||||
# the explicit dispatch is dead code. (And even if we wanted to
|
||||
# preserve it, Gitea has no `workflow_dispatch` REST endpoint.)
|
||||
#
|
||||
# Removed in this rewrite. If we ever observe the cascade misfire,
|
||||
# operator can push an empty commit to `main` to wake it.
|
||||
#
|
||||
# ============================================================
|
||||
# Why open a PR (and not direct push)
|
||||
# ============================================================
|
||||
#
|
||||
# `main` branch protection has `enable_push: false` with NO
|
||||
# `push_whitelist_usernames`. Direct push is impossible for any
|
||||
# persona, including admins. PR-mediated merge is the only path,
|
||||
# which is intentional: prod state mutations (and staging→main IS a
|
||||
# prod mutation, since the next deploy fans out to tenants) require
|
||||
# Hongming's approval per `feedback_prod_apply_needs_hongming_chat_go`.
|
||||
#
|
||||
# The auto-merge schedule preserves this gate: `merge_when_checks_succeed`
|
||||
# does NOT bypass `required_approvals: 1`. Gitea waits for BOTH
|
||||
# approval AND green checks before merging. Hongming reviews via the
|
||||
# canvas/chat-handle of the PR notification, approves, and Gitea
|
||||
# auto-merges within seconds.
|
||||
#
|
||||
# ============================================================
|
||||
# Identity + token (anti-bot-ring per saved-memory
|
||||
# `feedback_per_agent_gitea_identity_default`)
|
||||
# ============================================================
|
||||
#
|
||||
# This workflow uses `secrets.AUTO_SYNC_TOKEN` — a personal access
|
||||
# token issued to the `devops-engineer` Gitea persona. NOT the
|
||||
# founder PAT. The bot-ring fingerprint that triggered the GitHub
|
||||
# org suspension on 2026-05-06 was characterised by founder PAT
|
||||
# acting as CI at machine speed.
|
||||
#
|
||||
# Token scope: `push: true` (read+write) on this repo. The persona
|
||||
# can: open PRs, comment on PRs, schedule auto-merge. The persona
|
||||
# CANNOT bypass main's branch protection (`required_approvals: 1`
|
||||
# still applies — only Hongming's review unblocks merge).
|
||||
#
|
||||
# Authorship: the PR is opened by `devops-engineer`; the merge
|
||||
# commit credits Hongming-as-approver and `devops-engineer` as
|
||||
# the merger.
|
||||
#
|
||||
# ============================================================
|
||||
# Failure modes & operational notes
|
||||
# ============================================================
|
||||
#
|
||||
# A — staging gates not all green at trigger time:
|
||||
# - The combined-status check returns `state: pending|failure`.
|
||||
# Workflow exits 0 with a step-summary "not all green; staying
|
||||
# on current main". Re-fires on the next gate completion.
|
||||
#
|
||||
# B — Gitea PR-create returns non-201 (e.g. 422 already-exists):
|
||||
# - Idempotent: the workflow first GETs the existing open
|
||||
# staging→main PR. If found, reuse it; if not, POST a new one.
|
||||
# 422 should never surface; if it does (race), step summary
|
||||
# captures the body and the next workflow_run picks up.
|
||||
#
|
||||
# C — `merge_when_checks_succeed` schedule fails:
|
||||
# - 422 with "Pull request is not mergeable" if there are
|
||||
# conflicts or stale base. Step summary surfaces it; operator
|
||||
# (or `auto-sync-main-to-staging`) needs to bring staging up
|
||||
# to date with main first. Workflow exits 1 to surface red.
|
||||
#
|
||||
# D — `AUTO_SYNC_TOKEN` rotated / wrong scope:
|
||||
# - 401/403 on first REST call. Step summary surfaces it.
|
||||
# Re-issue the token from `~/.molecule-ai/personas/` on the
|
||||
# operator host and update the repo Actions secret.
|
||||
#
|
||||
# ============================================================
|
||||
# Loop safety
|
||||
# ============================================================
|
||||
#
|
||||
# When the promote PR merges to main, `auto-sync-main-to-staging.yml`
|
||||
# fires (on:push:main) and pushes the merge commit back to staging.
|
||||
# That push to staging is by `devops-engineer`, NOT this workflow's
|
||||
# token, and triggers the staging gate workflows. When they all
|
||||
# complete, we end up back here — but the tree-diff guard catches
|
||||
# it: staging tree == main tree (the merge commit changes nothing),
|
||||
# so we skip and the cycle terminates.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -74,26 +161,16 @@ on:
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
# actions: write is needed by the post-merge dispatch tail step
|
||||
# (#2358 / #2357) — `gh workflow run publish-workspace-server-image.yml`
|
||||
# POSTs to /actions/workflows/.../dispatches which requires this scope.
|
||||
# Without it the call 403s and the publish/canary/redeploy chain still
|
||||
# doesn't run on staging→main promotions, undoing #2358.
|
||||
actions: write
|
||||
|
||||
# Serialize auto-promote runs. Multiple staging gate completions can land
|
||||
# in quick succession (CI + E2E + CodeQL all finish within seconds of
|
||||
# each other on a green PR) — without this, two parallel runs both:
|
||||
# 1. Open / re-use the same promote PR.
|
||||
# 2. Both call `gh pr merge --auto` (idempotent — fine).
|
||||
# 3. Both poll for the same mergedAt and both `gh workflow run` publish
|
||||
# → 2× redundant publish builds racing for the same `:staging-latest`
|
||||
# retag, and 2× canary-verify chains.
|
||||
# cancel-in-progress: false because we don't want a brand-new run to kill
|
||||
# a polling-tail that's about to dispatch — the polling tail's 30 min cap
|
||||
# is the right backstop, not workflow-level cancel.
|
||||
# 1. Would race the GET-or-POST PR step.
|
||||
# 2. Would both call merge-schedule (idempotent — fine on Gitea).
|
||||
# cancel-in-progress: false because the second run on a fresh staging
|
||||
# tip should NOT kill the first which has already opened the PR.
|
||||
concurrency:
|
||||
group: auto-promote-staging
|
||||
cancel-in-progress: false
|
||||
@@ -111,126 +188,112 @@ jobs:
|
||||
all_green: ${{ steps.gates.outputs.all_green }}
|
||||
head_sha: ${{ steps.gates.outputs.head_sha }}
|
||||
steps:
|
||||
# Skip empty-tree promotes (the perpetual auto-promote↔auto-sync cycle
|
||||
# observed 2026-05-03). Sequence: auto-promote merges via the staging
|
||||
# merge-queue's MERGE strategy, creating a merge commit on main that
|
||||
# staging doesn't have. auto-sync then merges main back into staging
|
||||
# via another merge commit (the queue's MERGE strategy applies on
|
||||
# the staging side too, even when the workflow's local FF would
|
||||
# have sufficed). Now staging has a new merge-commit SHA whose
|
||||
# tree == main's tree — but auto-promote sees "staging ahead of
|
||||
# main by 1" and opens YET another empty promote PR. Each round
|
||||
# costs ~30-40 min wallclock, ~2 manual approvals, and burns a
|
||||
# full CodeQL Go run (~15 min). Without this guard the cycle
|
||||
# repeats indefinitely.
|
||||
#
|
||||
# Long-term fix is to switch the merge_queue ruleset's
|
||||
# `merge_method` away from MERGE so FF-able PRs land cleanly,
|
||||
# but that's a broader change affecting every staging PR's
|
||||
# commit shape. This guard is the one-line surgical fix that
|
||||
# breaks the cycle without touching merge-queue config.
|
||||
#
|
||||
# Fail-open: if `git diff` errors for any reason, fall through
|
||||
# to the gate check (preserve existing behavior). Only skip
|
||||
# when the diff is DEFINITIVELY empty.
|
||||
# Skip empty-tree promotes (the perpetual auto-promote↔auto-sync
|
||||
# cycle observed pre-cutover on GitHub). On Gitea the cycle shape
|
||||
# is different (auto-sync uses fast-forward, no merge commit),
|
||||
# but the tree-diff guard is cheap insurance and protects against
|
||||
# any future merge-style regression.
|
||||
- name: Checkout for tree-diff check
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: staging
|
||||
- name: Skip if staging tree == main tree (perpetual-cycle break)
|
||||
|
||||
- name: Skip if staging tree == main tree (cycle-break safety)
|
||||
id: tree-diff
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch origin main --depth=50 || { echo "::warning::git fetch main failed — proceeding (fail-open)"; exit 0; }
|
||||
# Compare staging tip's tree against main's tree. `git diff
|
||||
# --quiet` exits 0 if no differences, 1 if there are.
|
||||
if git diff --quiet origin/main "$HEAD_SHA" -- 2>/dev/null; then
|
||||
{
|
||||
echo "## ⏭ Skipped — no code to promote"
|
||||
echo "## Skipped — no code to promote"
|
||||
echo
|
||||
echo "staging tip (\`${HEAD_SHA:0:8}\`) and \`main\` have identical trees."
|
||||
echo "This is the auto-promote↔auto-sync merge-commit cycle: staging has a"
|
||||
echo "new SHA (a sync-back merge commit) but the underlying file tree is"
|
||||
echo "already on main, so there's no real code to ship."
|
||||
echo
|
||||
echo "Skipping to avoid opening an empty promote PR. Cycle terminates here."
|
||||
echo "Skipping to avoid opening an empty promote PR."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::auto-promote: staging tree == main tree — no code to promote, skipping"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check all required gates on this SHA
|
||||
|
||||
- name: Check combined status on staging head
|
||||
if: steps.tree-diff.outputs.skip != 'true'
|
||||
id: gates
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Required gate workflow files. Use file paths (relative to
|
||||
# .github/workflows/) rather than display names because:
|
||||
# Gitea-native combined-status endpoint aggregates every
|
||||
# check context attached to a SHA. This is structurally
|
||||
# cleaner than the GitHub-era per-workflow `gh run list`
|
||||
# loop because:
|
||||
#
|
||||
# 1. `gh run list --workflow=<name>` is ambiguous when two
|
||||
# workflows have the same `name:` — observed 2026-04-28
|
||||
# with "CodeQL" matching both `codeql.yml` (explicit) and
|
||||
# GitHub's UI-configured Code-quality default setup
|
||||
# (internal "codeql"). gh CLI returns "could not resolve
|
||||
# to a unique workflow" → empty result → gate evaluated
|
||||
# as missing/none → auto-promote dead-locked despite all
|
||||
# checks actually passing.
|
||||
# 1. There's no risk of "workflow name collision" (the
|
||||
# GitHub-era code had to switch from `--workflow=NAME`
|
||||
# to `--workflow=FILE.YML` to disambiguate "CodeQL"
|
||||
# between the explicit workflow and GitHub's UI-
|
||||
# configured default setup; Gitea has no such
|
||||
# duplicate-name surface).
|
||||
# 2. Gitea's combined state already encodes the AND
|
||||
# across all contexts: success only if EVERY context
|
||||
# is success. Pending or failure on any context
|
||||
# produces non-success state.
|
||||
#
|
||||
# 2. File paths are the unique identifier for workflows;
|
||||
# `name:` is just a display string and can collide.
|
||||
#
|
||||
# When adding/removing a gate, update this list AND the
|
||||
# branch-protection required-checks list (which uses check-run
|
||||
# display names, not workflow names; the two are decoupled and
|
||||
# should be kept in sync manually).
|
||||
GATES=(
|
||||
"ci.yml"
|
||||
"e2e-staging-canvas.yml"
|
||||
"e2e-api.yml"
|
||||
"codeql.yml"
|
||||
)
|
||||
# See https://docs.gitea.com/api/1.22 for the schema —
|
||||
# `state` is one of: success, pending, failure, error.
|
||||
|
||||
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking gates on SHA ${HEAD_SHA}"
|
||||
echo "Checking combined status on SHA ${HEAD_SHA}"
|
||||
|
||||
ALL_GREEN=true
|
||||
for gate in "${GATES[@]}"; do
|
||||
# Query the most recent run of this workflow on this SHA.
|
||||
# event=push to avoid picking up PR runs. branch=staging to
|
||||
# guard against someone dispatching the gate on a non-staging
|
||||
# branch at the same SHA.
|
||||
RESULT=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--workflow "$gate" \
|
||||
--branch staging \
|
||||
--event push \
|
||||
--commit "$HEAD_SHA" \
|
||||
--limit 1 \
|
||||
--json status,conclusion \
|
||||
--jq '.[0] | "\(.status)/\(.conclusion // "none")"' \
|
||||
2>/dev/null || echo "missing/none")
|
||||
# `set +o pipefail` for the http-code capture pattern; restore
|
||||
# immediately. Pattern hardened per `feedback_curl_status_capture_pollution`.
|
||||
BODY_FILE=$(mktemp)
|
||||
set +e
|
||||
STATUS=$(curl -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
-o "${BODY_FILE}" \
|
||||
-w "%{http_code}" \
|
||||
"${GITEA_HOST}/api/v1/repos/${REPO}/commits/${HEAD_SHA}/status")
|
||||
CURL_RC=$?
|
||||
set -e
|
||||
|
||||
echo " $gate → $RESULT"
|
||||
if [ "${CURL_RC}" -ne 0 ] || [ "${STATUS}" != "200" ]; then
|
||||
echo "::error::combined-status fetch failed: curl=${CURL_RC} http=${STATUS}"
|
||||
cat "${BODY_FILE}" | head -c 500 || true
|
||||
rm -f "${BODY_FILE}"
|
||||
echo "all_green=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only completed/success counts. completed/failure or
|
||||
# in_progress/anything or no record at all = abort.
|
||||
if [ "$RESULT" != "completed/success" ]; then
|
||||
ALL_GREEN=false
|
||||
fi
|
||||
done
|
||||
STATE=$(jq -r '.state // "missing"' < "${BODY_FILE}")
|
||||
TOTAL=$(jq -r '.total_count // 0' < "${BODY_FILE}")
|
||||
rm -f "${BODY_FILE}"
|
||||
|
||||
echo "all_green=${ALL_GREEN}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$ALL_GREEN" != "true" ]; then
|
||||
echo "::notice::auto-promote: not all gates are green on ${HEAD_SHA} — staying on current main"
|
||||
echo "Combined status: state=${STATE} total_count=${TOTAL}"
|
||||
|
||||
if [ "${STATE}" = "success" ] && [ "${TOTAL}" -gt 0 ]; then
|
||||
echo "all_green=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::All gates green on ${HEAD_SHA} (${TOTAL} contexts)"
|
||||
else
|
||||
echo "all_green=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Not promoting — combined status not green"
|
||||
echo
|
||||
echo "- SHA: \`${HEAD_SHA:0:8}\`"
|
||||
echo "- Combined state: \`${STATE}\`"
|
||||
echo "- Context count: ${TOTAL}"
|
||||
echo
|
||||
echo "Will re-fire on the next gate completion. Investigate any red gate via the Actions UI."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::auto-promote: combined status is ${STATE} on ${HEAD_SHA} — staying on current main"
|
||||
fi
|
||||
|
||||
promote:
|
||||
@@ -247,188 +310,183 @@ jobs:
|
||||
# Repo variable AUTO_PROMOTE_ENABLED=true flips this on. While
|
||||
# it's unset, the workflow dry-runs (logs what it would have
|
||||
# done) but doesn't open the promote PR. Set the variable in
|
||||
# Settings → Secrets and variables → Actions → Variables.
|
||||
# Settings → Actions → Variables.
|
||||
if [ "${AUTO_PROMOTE_ENABLED:-}" != "true" ] && [ "${FORCE_INPUT:-false}" != "true" ]; then
|
||||
{
|
||||
echo "## ⏸ Auto-promote disabled"
|
||||
echo "## Auto-promote disabled"
|
||||
echo
|
||||
echo "Repo variable \`AUTO_PROMOTE_ENABLED\` is not set to \`true\`."
|
||||
echo "All gates are green on staging; would have opened a promote PR to \`main\`."
|
||||
echo
|
||||
echo "To enable: Settings → Secrets and variables → Actions → Variables → \`AUTO_PROMOTE_ENABLED=true\`."
|
||||
echo "To enable: Settings → Actions → Variables → \`AUTO_PROMOTE_ENABLED=true\`."
|
||||
echo "To test once manually: workflow_dispatch with \`force=true\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::auto-promote disabled — dry run only"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Mint the App token BEFORE the promote-PR step so the auto-merge
|
||||
# call can use it. GITHUB_TOKEN-initiated merges suppress the
|
||||
# downstream `push` event on main, breaking the
|
||||
# publish-workspace-server-image → canary-verify → redeploy-tenants
|
||||
# chain (issue #2357). Using the App token here means the
|
||||
# merge-queue-landed merge IS able to fire the cascade naturally;
|
||||
# the polling tail below stays as defense-in-depth.
|
||||
- name: Mint App token for promote-PR + downstream dispatch
|
||||
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.MOLECULE_AI_APP_ID }}
|
||||
private-key: ${{ secrets.MOLECULE_AI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open (or reuse) staging → main promote PR + enable auto-merge
|
||||
- name: Open or reuse promote PR + schedule auto-merge
|
||||
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
|
||||
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Look for an existing open promote PR (idempotent on re-run
|
||||
# of the workflow). The PR's head IS the staging branch — the
|
||||
# whole point is "advance main to staging's tip", so we don't
|
||||
# need a per-SHA branch like auto-sync-main-to-staging uses.
|
||||
PR_NUM=$(gh pr list --repo "$REPO" \
|
||||
--base main --head staging --state open \
|
||||
--json number --jq '.[0].number // ""')
|
||||
API="${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
|
||||
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
# http_status_get RESULT_VAR URL
|
||||
# Sets RESULT_VAR to "<http_code>:<body_file>". Curl status
|
||||
# capture pattern per `feedback_curl_status_capture_pollution`:
|
||||
# http_code goes to its own tempfile-equivalent (-w), body to
|
||||
# another tempfile, set +e/-e bracket protects pipeline state.
|
||||
http_get() {
|
||||
local body_file="$1"; shift
|
||||
local url="$1"; shift
|
||||
set +e
|
||||
local code
|
||||
code=$(curl -sS "${AUTH[@]}" -o "${body_file}" -w "%{http_code}" "${url}")
|
||||
local rc=$?
|
||||
set -e
|
||||
if [ "${rc}" -ne 0 ]; then
|
||||
echo "::error::curl GET failed (rc=${rc}) on ${url}"
|
||||
return 99
|
||||
fi
|
||||
echo "${code}"
|
||||
}
|
||||
http_post_json() {
|
||||
local body_file="$1"; shift
|
||||
local data="$1"; shift
|
||||
local url="$1"; shift
|
||||
set +e
|
||||
local code
|
||||
code=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
|
||||
-X POST -d "${data}" -o "${body_file}" -w "%{http_code}" "${url}")
|
||||
local rc=$?
|
||||
set -e
|
||||
if [ "${rc}" -ne 0 ]; then
|
||||
echo "::error::curl POST failed (rc=${rc}) on ${url}"
|
||||
return 99
|
||||
fi
|
||||
echo "${code}"
|
||||
}
|
||||
|
||||
# Step 1: look for an existing open staging→main promote PR
|
||||
# (idempotent on workflow re-run). Gitea doesn't have a
|
||||
# head/base filter on the list endpoint that's as ergonomic
|
||||
# as gh's, but the dedicated `/pulls/{base}/{head}` lookup
|
||||
# works.
|
||||
BODY=$(mktemp)
|
||||
STATUS=$(http_get "${BODY}" "${API}/pulls/main/staging") || true
|
||||
|
||||
PR_NUM=""
|
||||
if [ "${STATUS}" = "200" ]; then
|
||||
STATE=$(jq -r '.state // "missing"' < "${BODY}")
|
||||
if [ "${STATE}" = "open" ]; then
|
||||
PR_NUM=$(jq -r '.number // ""' < "${BODY}")
|
||||
echo "::notice::Re-using existing open promote PR #${PR_NUM}"
|
||||
fi
|
||||
fi
|
||||
rm -f "${BODY}"
|
||||
|
||||
# Step 2: if no open PR, create one.
|
||||
if [ -z "${PR_NUM}" ]; then
|
||||
TITLE="staging → main: auto-promote ${TARGET_SHA:0:7}"
|
||||
BODY_FILE=$(mktemp)
|
||||
cat > "$BODY_FILE" <<EOFBODY
|
||||
Automated promotion of \`staging\` (\`${TARGET_SHA:0:8}\`) to \`main\`. All required staging gates green at this SHA: CI, E2E Staging Canvas, E2E API Smoke, CodeQL.
|
||||
BODY_TEXT=$(cat <<EOFBODY
|
||||
Automated promotion of \`staging\` (\`${TARGET_SHA:0:8}\`) to \`main\`. All required staging gates are green at this SHA (combined status reported success).
|
||||
|
||||
This PR is auto-generated by \`.github/workflows/auto-promote-staging.yml\` whenever every required gate completes green on the same staging SHA. It exists because main's branch protection requires status checks "set by the expected GitHub apps" — direct \`git push\` from a workflow can't satisfy that, only PR merges through the queue can.
|
||||
This PR is auto-generated by \`.github/workflows/auto-promote-staging.yml\` whenever every required gate completes green on the same staging SHA.
|
||||
|
||||
Merge queue lands this; no human action needed unless gates fail. Reverse-direction sync (the merge commit on main → staging) is handled by \`auto-sync-main-to-staging.yml\`.
|
||||
**Approval gate:** \`main\` branch protection requires 1 approval before this can land. Once approved, Gitea will auto-merge (the workflow scheduled \`merge_when_checks_succeed: true\` immediately after open).
|
||||
|
||||
The reverse-direction sync (the merge commit on \`main\` → \`staging\`) is handled automatically by \`auto-sync-main-to-staging.yml\` after this PR lands.
|
||||
|
||||
---
|
||||
- Source: staging at \`${TARGET_SHA}\`
|
||||
- Opened by: \`devops-engineer\` persona (anti-bot-ring; never founder PAT)
|
||||
- Refs: #65, #73, #195
|
||||
EOFBODY
|
||||
PR_URL=$(gh pr create --repo "$REPO" \
|
||||
--base main --head staging \
|
||||
--title "$TITLE" \
|
||||
--body-file "$BODY_FILE")
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$' | tail -1)
|
||||
rm -f "$BODY_FILE"
|
||||
echo "::notice::Opened PR #${PR_NUM}"
|
||||
else
|
||||
echo "::notice::Re-using existing promote PR #${PR_NUM}"
|
||||
)
|
||||
REQ=$(jq -n \
|
||||
--arg title "${TITLE}" \
|
||||
--arg body "${BODY_TEXT}" \
|
||||
--arg base "main" \
|
||||
--arg head "staging" \
|
||||
'{title:$title, body:$body, base:$base, head:$head}')
|
||||
|
||||
BODY=$(mktemp)
|
||||
STATUS=$(http_post_json "${BODY}" "${REQ}" "${API}/pulls")
|
||||
|
||||
if [ "${STATUS}" = "201" ]; then
|
||||
PR_NUM=$(jq -r '.number // ""' < "${BODY}")
|
||||
echo "::notice::Opened promote PR #${PR_NUM}"
|
||||
else
|
||||
echo "::error::Failed to create promote PR: HTTP ${STATUS}"
|
||||
jq -r '.message // .' < "${BODY}" | head -c 500
|
||||
rm -f "${BODY}"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "${BODY}"
|
||||
fi
|
||||
|
||||
# Enable auto-merge — the merge queue picks it up once
|
||||
# required gates are green on the merge_group ref.
|
||||
if ! gh pr merge "$PR_NUM" --repo "$REPO" --auto --merge 2>&1; then
|
||||
echo "::warning::Failed to enable auto-merge on PR #${PR_NUM} — operator may need to merge manually."
|
||||
fi
|
||||
# Step 3: schedule auto-merge. merge_when_checks_succeed
|
||||
# tells Gitea to wait for both:
|
||||
# - all required status checks to pass
|
||||
# - the required-approvals gate (1 approval on main)
|
||||
# before merging. On approval+green, Gitea merges within
|
||||
# seconds. On any check failing or approval being denied,
|
||||
# the schedule stays armed but doesn't fire.
|
||||
#
|
||||
# Idempotent: re-arming on an already-armed PR is a no-op.
|
||||
REQ=$(jq -n '{Do:"merge", merge_when_checks_succeed:true}')
|
||||
BODY=$(mktemp)
|
||||
STATUS=$(http_post_json "${BODY}" "${REQ}" "${API}/pulls/${PR_NUM}/merge")
|
||||
|
||||
# Gitea returns:
|
||||
# - 200/204 on successful immediate merge (gates already green AND approved)
|
||||
# - 405 "Please try again later" when scheduled successfully but waiting
|
||||
# - 422 on "Pull request is not mergeable" (conflict, stale base, etc.)
|
||||
#
|
||||
# 405 here is benign — Gitea's way of saying "scheduled, not merging now".
|
||||
# We treat 200/204/405 as success, anything else as failure.
|
||||
case "${STATUS}" in
|
||||
200|204)
|
||||
MERGE_OUTCOME="merged-immediately"
|
||||
echo "::notice::Promote PR #${PR_NUM} merged immediately (gates+approval already green)"
|
||||
;;
|
||||
405)
|
||||
MERGE_OUTCOME="auto-merge-scheduled"
|
||||
echo "::notice::Promote PR #${PR_NUM}: auto-merge scheduled (Gitea will land on approval+green)"
|
||||
;;
|
||||
422)
|
||||
MERGE_OUTCOME="not-mergeable"
|
||||
echo "::warning::Promote PR #${PR_NUM}: not mergeable (conflict, stale base, or already merging)."
|
||||
jq -r '.message // .' < "${BODY}" | head -c 500
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unexpected status ${STATUS} on merge schedule"
|
||||
jq -r '.message // .' < "${BODY}" | head -c 500
|
||||
rm -f "${BODY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
rm -f "${BODY}"
|
||||
|
||||
{
|
||||
echo "## ✅ Auto-promote PR opened"
|
||||
echo "## Auto-promote PR opened"
|
||||
echo
|
||||
echo "- Source: staging at \`${TARGET_SHA:0:8}\`"
|
||||
echo "- PR: #${PR_NUM}"
|
||||
echo "- Outcome: \`${MERGE_OUTCOME}\`"
|
||||
echo
|
||||
echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
|
||||
if [ "${MERGE_OUTCOME}" = "auto-merge-scheduled" ]; then
|
||||
echo "Gitea will auto-merge once Hongming approves and all checks are green. No human action needed beyond approval."
|
||||
elif [ "${MERGE_OUTCOME}" = "merged-immediately" ]; then
|
||||
echo "Merged immediately. \`publish-workspace-server-image.yml\` will fire naturally on the resulting \`main\` push."
|
||||
else
|
||||
echo "PR is not auto-merging. Operator may need to bring staging up to date with main, then re-trigger this workflow via workflow_dispatch."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Hand the PR number to the next step so we can dispatch the
|
||||
# tenant-redeploy chain after the merge queue lands the merge.
|
||||
echo "promote_pr_num=${PR_NUM}" >> "$GITHUB_OUTPUT"
|
||||
id: promote_pr
|
||||
|
||||
# The App token minted above (before the promote-PR step) is
|
||||
# also used by the polling tail below. Defense-in-depth: with
|
||||
# the merge-queue-landed merge now using the App token, the
|
||||
# main-branch push event SHOULD fire the publish/canary/redeploy
|
||||
# cascade naturally — but if for any reason it doesn't (e.g. an
|
||||
# unrelated event-suppression edge case), the explicit dispatches
|
||||
# below still wake the chain.
|
||||
- name: Wait for promote merge, then dispatch publish + redeploy (#2357)
|
||||
# Defense-in-depth dispatch. With the auto-merge call above
|
||||
# now using the App token (this commit), the merge-queue-landed
|
||||
# merge SHOULD fire publish-workspace-server-image naturally
|
||||
# via on:push:[main] — App-token-initiated pushes DO trigger
|
||||
# workflow_run cascades, unlike GITHUB_TOKEN-initiated ones
|
||||
# (the documented "no recursion" rule —
|
||||
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
|
||||
#
|
||||
# This explicit dispatch stays as belt-and-suspenders for any
|
||||
# edge case where the natural cascade misfires. If it never
|
||||
# observably fires after this token swap (i.e. the publish
|
||||
# workflow has already started by the time we get here), the
|
||||
# second dispatch is a harmless no-op (publish-workspace-server-image
|
||||
# has its own concurrency group that dedupes).
|
||||
#
|
||||
# See PR for #2357: pre-fix the merge action was via
|
||||
# GITHUB_TOKEN, suppressing the cascade and forcing this tail
|
||||
# to be the SOLE chain trigger. With the auto-merge token swap
|
||||
# the tail becomes redundant in the happy path; keep until
|
||||
# we've observed >=10 successful natural cascades, then drop.
|
||||
if: steps.promote_pr.outputs.promote_pr_num != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUM: ${{ steps.promote_pr.outputs.promote_pr_num }}
|
||||
run: |
|
||||
# Poll for merge — max 30 min (60 × 30s). The merge queue
|
||||
# typically lands within 5-10 min when gates are green. Break
|
||||
# early if the PR is closed without merging (operator action,
|
||||
# gates flipped red post-approval, branch-protection rejection)
|
||||
# so we don't tie up a runner for the full 30 min on a dead PR.
|
||||
MERGED=""
|
||||
STATE=""
|
||||
for _ in $(seq 1 60); do
|
||||
VIEW=$(gh pr view "$PR_NUM" --repo "$REPO" --json mergedAt,state)
|
||||
MERGED=$(echo "$VIEW" | jq -r '.mergedAt // ""')
|
||||
STATE=$(echo "$VIEW" | jq -r '.state // ""')
|
||||
if [ -n "$MERGED" ] && [ "$MERGED" != "null" ]; then
|
||||
echo "::notice::Promote PR #${PR_NUM} merged at ${MERGED}"
|
||||
break
|
||||
fi
|
||||
if [ "$STATE" = "CLOSED" ]; then
|
||||
echo "::warning::Promote PR #${PR_NUM} was closed without merging — skipping deploy dispatch."
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
|
||||
if [ -z "$MERGED" ] || [ "$MERGED" = "null" ]; then
|
||||
echo "::warning::Promote PR #${PR_NUM} didn't merge within 30min — skipping deploy dispatch (manually run \`gh workflow run publish-workspace-server-image.yml --ref main\` once it lands)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Dispatch publish on main using the App token. App-initiated
|
||||
# workflow_dispatch DOES propagate the workflow_run cascade,
|
||||
# unlike GITHUB_TOKEN-initiated dispatch.
|
||||
# publish completes → canary-verify chains via workflow_run →
|
||||
# redeploy-tenants-on-main chains via workflow_run + branches:[main].
|
||||
if gh workflow run publish-workspace-server-image.yml \
|
||||
--repo "$REPO" --ref main 2>&1; then
|
||||
echo "::notice::Dispatched publish-workspace-server-image on ref=main as molecule-ai App — canary-verify and redeploy-tenants-on-main will chain via workflow_run."
|
||||
{
|
||||
echo "## 🚀 Tenant redeploy chain dispatched"
|
||||
echo
|
||||
echo "- publish-workspace-server-image (workflow_dispatch on \`main\`, actor: \`molecule-ai[bot]\`)"
|
||||
echo "- canary-verify will chain on completion"
|
||||
echo "- redeploy-tenants-on-main will chain on canary green"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::error::Failed to dispatch publish-workspace-server-image. Run manually: gh workflow run publish-workspace-server-image.yml --ref main"
|
||||
fi
|
||||
|
||||
# ALSO dispatch auto-sync-main-to-staging.yml. Same root cause as
|
||||
# publish above (issue #2357): the merge-queue-initiated push to
|
||||
# main is by GITHUB_TOKEN → no `on: push` triggers fire downstream.
|
||||
# Without this dispatch, every staging→main promote leaves staging
|
||||
# one merge commit BEHIND main, which silently dead-locks the NEXT
|
||||
# promote PR as `mergeStateStatus: BEHIND` because main's
|
||||
# branch-protection has `strict: true`. Verified empirically on
|
||||
# 2026-05-02 against PR #2442 (Phase 2 promote): only the explicit
|
||||
# publish-workspace-server-image dispatch fired on the previous
|
||||
# promote SHA 76c604fb, while auto-sync silently no-op'd, leaving
|
||||
# staging behind for ~24h until manually bridged.
|
||||
if gh workflow run auto-sync-main-to-staging.yml \
|
||||
--repo "$REPO" --ref main 2>&1; then
|
||||
echo "::notice::Dispatched auto-sync-main-to-staging on ref=main as molecule-ai App — staging will absorb the new main merge commit via PR + merge queue."
|
||||
else
|
||||
echo "::error::Failed to dispatch auto-sync-main-to-staging. Run manually: gh workflow run auto-sync-main-to-staging.yml --ref main"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: auto-promote-stale-alarm
|
||||
|
||||
# Hourly cron + on-demand alarm for the silent-block failure mode that
|
||||
# motivated issue #2975:
|
||||
# - The auto-promote-staging.yml workflow opened a PR + armed
|
||||
# auto-merge, but main's branch protection requires a human review
|
||||
# (reviewDecision=REVIEW_REQUIRED). The PR sat BLOCKED with no
|
||||
# surface-up-the-stack for 12+ hours, holding 25 commits hostage
|
||||
# including the Memory v2 redesign and a reno-stars data-loss fix.
|
||||
#
|
||||
# This workflow runs `scripts/check-stale-promote-pr.sh` against the
|
||||
# repo's open auto-promote PRs (base=main head=staging). When a PR has
|
||||
# been BLOCKED on REVIEW_REQUIRED for >4h, it:
|
||||
# 1. Emits a workflow-level warning (visible in run summary + the
|
||||
# Actions UI feed).
|
||||
# 2. Posts a comment on the PR (idempotent — one alarm per PR).
|
||||
#
|
||||
# The detection logic lives in scripts/check-stale-promote-pr.sh so
|
||||
# it's unit-testable with stubbed `gh` (see test-check-stale-promote-pr.sh).
|
||||
# This file is the schedule + invocation surface only — SSOT for the
|
||||
# detector itself.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly. Cheap (one `gh pr list` + jq), and 1h granularity is
|
||||
# plenty for a 4h staleness threshold — operators see the alarm
|
||||
# within at most 1h of crossing the threshold.
|
||||
- cron: "27 * * * *" # at :27 to dodge the cron herd at :00
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_hours:
|
||||
description: "Hours after which a BLOCKED+REVIEW_REQUIRED PR is stale (default 4)"
|
||||
required: false
|
||||
default: "4"
|
||||
post_comment:
|
||||
description: "Post a comment on stale PRs (default true)"
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post comments on stale PRs
|
||||
|
||||
# Serialize so the on-demand and scheduled runs don't double-comment
|
||||
# the same PR. cancel-in-progress=false because the script is idempotent
|
||||
# (existing comment marker prevents dupes), but a scheduled run firing
|
||||
# while a manual one runs would just re-list the same PR set.
|
||||
concurrency:
|
||||
group: auto-promote-stale-alarm
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (need scripts/ only)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts/check-stale-promote-pr.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run stale-PR detector
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
STALE_HOURS: ${{ inputs.stale_hours || '4' }}
|
||||
POST_COMMENT: ${{ inputs.post_comment || 'true' }}
|
||||
run: |
|
||||
# The script's exit code reflects the count of stale PRs.
|
||||
# We don't want a stale finding to fail the workflow run —
|
||||
# the warning + comment are the signal, the green/red is
|
||||
# noise. So convert any non-zero exit to a workflow notice
|
||||
# and exit 0.
|
||||
set +e
|
||||
bash scripts/check-stale-promote-pr.sh
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::notice::Stale PR detector found $rc PR(s) needing attention. See warnings above + comments on the PRs."
|
||||
fi
|
||||
# Always succeed — operator-facing surface is the warning,
|
||||
# not the workflow status.
|
||||
exit 0
|
||||
@@ -0,0 +1,404 @@
|
||||
name: Auto-sync canary — AUTO_SYNC_TOKEN rotation drift
|
||||
|
||||
# Synthetic health check for the AUTO_SYNC_TOKEN secret consumed by
|
||||
# auto-sync-main-to-staging.yml (PR #66) and publish-workspace-server-image.yml.
|
||||
#
|
||||
# ============================================================
|
||||
# Why this workflow exists
|
||||
# ============================================================
|
||||
#
|
||||
# PR #66 fixed auto-sync (replaced GitHub-era `gh pr create` — which
|
||||
# 405s on Gitea's GraphQL endpoint — with a direct git push from the
|
||||
# `devops-engineer` persona's `AUTO_SYNC_TOKEN`). Hostile self-review
|
||||
# weakest spot #3 of that PR:
|
||||
#
|
||||
# "Token rotation silently breaks auto-sync. If AUTO_SYNC_TOKEN is
|
||||
# rotated without updating the repo secret, every push to main
|
||||
# fails red on the auto-sync push step. The workflow surfaces the
|
||||
# failure mode in the step summary (failure mode B in the header),
|
||||
# but there's no proactive monitoring."
|
||||
#
|
||||
# Detection latency under the status quo: rotation is only caught on
|
||||
# the next push to `main`. During quiet periods (no main push for
|
||||
# many hours) the staging-superset-of-main invariant silently breaks.
|
||||
#
|
||||
# This workflow closes the gap: every 6 hours, it fires the auth
|
||||
# surface that auto-sync depends on and emits a red workflow status
|
||||
# if AUTO_SYNC_TOKEN has drifted out of validity.
|
||||
#
|
||||
# ============================================================
|
||||
# What this checks (Option B — read-only verify)
|
||||
# ============================================================
|
||||
#
|
||||
# 1. `GET /api/v1/user` against Gitea with the token → validates the
|
||||
# token authenticates AND resolves to `devops-engineer` (catches
|
||||
# the case where the token was regenerated under a different
|
||||
# persona by mistake).
|
||||
# 2. `GET /api/v1/repos/molecule-ai/molecule-core` with the token →
|
||||
# validates the token has `read:repository` scope on this repo
|
||||
# (the v2 scope contract — see saved memory
|
||||
# `reference_persona_token_v2_scope`).
|
||||
# 3. `git push --dry-run` of the current staging SHA back to
|
||||
# `refs/heads/staging` via `https://oauth2:<token>@<gitea>/...`
|
||||
# → validates the EXACT HTTPS basic-auth path that
|
||||
# `actions/checkout` + `git push origin staging` use inside
|
||||
# auto-sync-main-to-staging.yml. NOP by construction (push the
|
||||
# current tip to itself = "Everything up-to-date"); auth is
|
||||
# checked at the smart-protocol handshake BEFORE the empty-diff
|
||||
# computation, so bad token → exit 128 with "Authentication
|
||||
# failed". `git ls-remote` is NOT used here because Gitea
|
||||
# falls back to anonymous read on public repos and would
|
||||
# silently green-light a rotated token.
|
||||
#
|
||||
# Each step exits non-zero with an actionable error message if it
|
||||
# fails. The workflow status itself is the operator-facing surface.
|
||||
#
|
||||
# ============================================================
|
||||
# What this does NOT check (intentional)
|
||||
# ============================================================
|
||||
#
|
||||
# - **Branch-protection authz** (failure mode C in auto-sync header):
|
||||
# would require an actual write to staging. Already monitored by
|
||||
# `branch-protection-drift.yml` daily. Don't duplicate.
|
||||
# - **Conflict resolution** (failure mode A): a real conflict is data-
|
||||
# driven, not auth-driven; can't synthesise it without polluting
|
||||
# staging. Already surfaces immediately on the next main push.
|
||||
# - **Concurrency** (failure mode D): handled by workflow concurrency
|
||||
# group on auto-sync, not a credential issue.
|
||||
#
|
||||
# ============================================================
|
||||
# Why Option B (read-only) and not the alternatives
|
||||
# ============================================================
|
||||
#
|
||||
# Considered + rejected (see issue #72 for full write-up):
|
||||
#
|
||||
# - **Option A — full auto-sync on schedule**: every run creates a
|
||||
# no-op merge commit on staging when main hasn't advanced. 4 noise
|
||||
# commits/day. And races the real `push:` trigger when main has
|
||||
# advanced. Rejected.
|
||||
#
|
||||
# - **Option C — push to dedicated `auto-sync-canary` branch**: would
|
||||
# exercise authz too, but adds branch noise on Gitea AND requires
|
||||
# maintaining a second branch protection (or expanding staging's
|
||||
# whitelist to a junk branch). Authz already covered by
|
||||
# `branch-protection-drift.yml`. Rejected.
|
||||
#
|
||||
# Prior art for the chosen Option B shape:
|
||||
# - Cloudflare's `/user/tokens/verify` endpoint (read-only auth
|
||||
# probe explicitly designed for credential canaries).
|
||||
# - AWS Secrets Manager rotation Lambda's `testSecret` step (auth
|
||||
# probe before promoting AWSPENDING → AWSCURRENT).
|
||||
# - HashiCorp Vault's `vault token lookup` for renewal canaries.
|
||||
#
|
||||
# ============================================================
|
||||
# Operator runbook — what to do when this workflow goes RED
|
||||
# ============================================================
|
||||
#
|
||||
# 1. **Identify which step failed**:
|
||||
# - Step "Verify token authenticates as devops-engineer" red →
|
||||
# token is invalid OR resolves to wrong persona.
|
||||
# - Step "Verify token has repo read scope" red → token valid but
|
||||
# stripped of `read:repository` scope (or repo perms changed).
|
||||
# - Step "Verify git HTTPS auth path via no-op dry-run push to
|
||||
# staging" red → token rotated/revoked OR Gitea git-HTTPS
|
||||
# surface is broken (rare). Auth check happens on the
|
||||
# smart-protocol handshake, separate from the API path.
|
||||
#
|
||||
# 2. **Re-issue the token** on the operator host:
|
||||
# ```
|
||||
# ssh root@5.78.80.188 'docker exec --user git molecule-gitea-1 \
|
||||
# gitea admin user generate-access-token \
|
||||
# --username devops-engineer \
|
||||
# --token-name persona-devops-engineer-vN \
|
||||
# --scopes "read:repository,write:repository,read:user,read:organization,read:issue,write:issue,read:notification,read:misc"'
|
||||
# ```
|
||||
# Update `/etc/molecule-bootstrap/agent-secrets.env` in place
|
||||
# (per `feedback_unified_credentials_file`). The previous token
|
||||
# file lands at `.bak.<date>`.
|
||||
#
|
||||
# 3. **Update the repo Actions secret** at:
|
||||
# Settings → Secrets and variables → Actions → AUTO_SYNC_TOKEN
|
||||
# Paste the new token. (Don't echo it in chat — but per
|
||||
# `feedback_passwords_in_chat_are_burned`, a paste in a 1:1
|
||||
# Claude session is within trust boundary.)
|
||||
#
|
||||
# 4. **Re-run this canary** via workflow_dispatch. Confirm GREEN.
|
||||
#
|
||||
# 5. **Backfill any missed main → staging syncs** by re-running
|
||||
# `auto-sync-main-to-staging.yml` from its workflow_dispatch
|
||||
# surface, OR by pushing an empty commit to main (if you'd
|
||||
# rather force a real trigger).
|
||||
#
|
||||
# ============================================================
|
||||
# Security notes
|
||||
# ============================================================
|
||||
#
|
||||
# - Token usage: read-only (`GET /api/v1/user`, `GET /api/v1/repos/...`,
|
||||
# `git ls-remote`). No write paths. Same blast-radius profile as
|
||||
# `actions/checkout` on a public repo.
|
||||
# - The token NEVER appears in logs: every `curl` uses a header
|
||||
# variable, never inline; the `git ls-remote` URL builds the
|
||||
# `oauth2:$TOKEN@host` form into a single env var that's not
|
||||
# echoed. GitHub Actions secret-masking covers anything that does
|
||||
# slip through.
|
||||
# - No new token introduced — same `AUTO_SYNC_TOKEN` the workflow
|
||||
# under monitor uses. Per least-privilege we deliberately do NOT
|
||||
# broaden scope for the canary.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 6 hours at :17 (offsets the cron herd at :00). Justification
|
||||
# from issue #72: cheap to run (~5s wall-clock, no quota), 3h average
|
||||
# detection latency, 6h max. 1h would be 24× the runs for marginal
|
||||
# benefit; daily would be 6× longer latency and worse than status
|
||||
# quo on a quiet-main day.
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# No concurrency group needed — the canary is read-only and idempotent.
|
||||
# Two parallel runs (e.g. operator dispatch during a scheduled tick) are
|
||||
# harmless: same result, doubled HTTPS calls, no shared state.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-token:
|
||||
name: Verify AUTO_SYNC_TOKEN validity
|
||||
runs-on: ubuntu-latest
|
||||
# 2 min surfaces hangs (Gitea API stall, DNS issue) within one
|
||||
# cron interval. Realistic worst case is ~10s: 2 curls + 1 git
|
||||
# ls-remote, each capped by the explicit timeouts below.
|
||||
timeout-minutes: 2
|
||||
|
||||
env:
|
||||
# Pinned in env so individual steps can read it without
|
||||
# repeating the secret reference. GitHub masks the value in
|
||||
# logs automatically.
|
||||
AUTO_SYNC_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
# MUST stay in sync with auto-sync-main-to-staging.yml's
|
||||
# `git config user.name "devops-engineer"` line. Renaming the
|
||||
# devops-engineer persona requires updating both files (and
|
||||
# the staging branch protection's `push_whitelist_usernames`).
|
||||
EXPECTED_PERSONA: devops-engineer
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO_PATH: molecule-ai/molecule-core
|
||||
|
||||
steps:
|
||||
- name: Verify AUTO_SYNC_TOKEN secret is configured
|
||||
# Schedule-vs-dispatch behaviour split, per
|
||||
# `feedback_schedule_vs_dispatch_secrets_hardening`:
|
||||
#
|
||||
# - schedule: hard-fail when the secret is missing. The
|
||||
# whole point of the canary is to surface drift; soft-
|
||||
# skipping on missing-secret would make the canary
|
||||
# itself drift-invisible (sweep-cf-orphans #2088 lesson).
|
||||
# - workflow_dispatch: hard-fail too — there's no scenario
|
||||
# where an operator wants this canary to silently no-op.
|
||||
# The workflow has no other ad-hoc utility; if you ran
|
||||
# it, you wanted the answer.
|
||||
run: |
|
||||
if [ -z "${AUTO_SYNC_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is not set on this repo." >&2
|
||||
echo "::error::Set it at Settings → Secrets and variables → Actions." >&2
|
||||
echo "::error::Without it, auto-sync-main-to-staging.yml will fail every push to main." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "AUTO_SYNC_TOKEN is configured (value masked)."
|
||||
|
||||
- name: Verify token authenticates as ${{ env.EXPECTED_PERSONA }}
|
||||
# Calls Gitea's `/api/v1/user` — the canonical
|
||||
# auth-probe-with-no-side-effects endpoint (mirrors
|
||||
# Cloudflare's /user/tokens/verify).
|
||||
#
|
||||
# Failure surfaces:
|
||||
# - HTTP 401: token invalid (rotated, revoked, or never
|
||||
# correctly registered).
|
||||
# - HTTP 200 but username != devops-engineer: token was
|
||||
# regenerated under the wrong persona — this would let
|
||||
# auth pass but commit attribution would be wrong, and
|
||||
# branch-protection authz would fail because only
|
||||
# `devops-engineer` is whitelisted.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
response_file="$(mktemp)"
|
||||
code_file="$(mktemp)"
|
||||
# `--max-time 30`: full call ceiling. `--connect-timeout 10`:
|
||||
# DNS + TCP. `-w "%{http_code}"` routed to a tempfile so curl's
|
||||
# exit code can't pollute the captured status — see
|
||||
# feedback_curl_status_capture_pollution + the
|
||||
# `lint-curl-status-capture.yml` gate that rejects the unsafe
|
||||
# `$(curl ... || echo "000")` shape.
|
||||
set +e
|
||||
curl -sS -o "$response_file" \
|
||||
--max-time 30 --connect-timeout 10 \
|
||||
-w "%{http_code}" \
|
||||
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"https://${GITEA_HOST}/api/v1/user" >"$code_file" 2>/dev/null
|
||||
set -e
|
||||
status=$(cat "$code_file" 2>/dev/null || true)
|
||||
[ -z "$status" ] && status="000"
|
||||
|
||||
if [ "$status" != "200" ]; then
|
||||
echo "::error::Token rotation suspected: GET /api/v1/user returned HTTP $status (expected 200)." >&2
|
||||
echo "::error::Likely cause: AUTO_SYNC_TOKEN has been rotated/revoked on Gitea but the repo Actions secret was not updated." >&2
|
||||
echo "::error::Runbook: see header comment of this workflow file." >&2
|
||||
# Print response body but redact anything that looks like a token.
|
||||
sed -E 's/[A-Fa-f0-9]{32,}/<redacted>/g' "$response_file" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
username=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('login',''))" "$response_file")
|
||||
if [ "$username" != "${EXPECTED_PERSONA}" ]; then
|
||||
echo "::error::Token resolves to user '$username', expected '${EXPECTED_PERSONA}'." >&2
|
||||
echo "::error::AUTO_SYNC_TOKEN must be the devops-engineer persona PAT (not founder PAT, not another persona)." >&2
|
||||
echo "::error::Auto-sync push will fail because only 'devops-engineer' is whitelisted on staging branch protection." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Token authenticates as: $username ✓"
|
||||
|
||||
- name: Verify token has repo read scope
|
||||
# `GET /api/v1/repos/<owner>/<repo>` requires `read:repository`
|
||||
# on the persona's v2 scope contract. If the scope was
|
||||
# narrowed/dropped on rotation we catch it here, before the
|
||||
# next main push reveals it via a checkout failure.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
response_file="$(mktemp)"
|
||||
code_file="$(mktemp)"
|
||||
# See first probe step for the rationale on the tempfile-routed
|
||||
# `-w "%{http_code}"` pattern — the unsafe `|| echo "000"` shape
|
||||
# is rejected by lint-curl-status-capture.yml.
|
||||
set +e
|
||||
curl -sS -o "$response_file" \
|
||||
--max-time 30 --connect-timeout 10 \
|
||||
-w "%{http_code}" \
|
||||
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO_PATH}" >"$code_file" 2>/dev/null
|
||||
set -e
|
||||
status=$(cat "$code_file" 2>/dev/null || true)
|
||||
[ -z "$status" ] && status="000"
|
||||
|
||||
if [ "$status" != "200" ]; then
|
||||
echo "::error::Token lacks read:repository scope on ${REPO_PATH}: HTTP $status." >&2
|
||||
echo "::error::Auto-sync's actions/checkout step will fail with this token." >&2
|
||||
echo "::error::Re-issue with v2 scope contract: read:repository,write:repository,read:user,read:organization,read:issue,write:issue,read:notification,read:misc" >&2
|
||||
sed -E 's/[A-Fa-f0-9]{32,}/<redacted>/g' "$response_file" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Token has read:repository on ${REPO_PATH} ✓"
|
||||
|
||||
- name: Verify git HTTPS auth path via no-op dry-run push to staging
|
||||
# Final probe: exercise the EXACT auth path that
|
||||
# `actions/checkout` + `git push origin staging` use in
|
||||
# auto-sync-main-to-staging.yml. Gitea's API and git-HTTPS
|
||||
# surfaces share the token-lookup code path internally but
|
||||
# the wire-level error shapes differ — historically (#173)
|
||||
# the API path was healthy while git-HTTPS rejected, so
|
||||
# checking only the API would have given false-green.
|
||||
#
|
||||
# IMPORTANT: `git ls-remote` on a public repo (which
|
||||
# molecule-core is) succeeds even with a junk token because
|
||||
# Gitea falls back to anonymous-read. `ls-remote` therefore
|
||||
# CANNOT validate auth on this surface. We use
|
||||
# `git push --dry-run` instead — push is auth-gated even on
|
||||
# public repos.
|
||||
#
|
||||
# NOP shape: read the current staging SHA via authenticated
|
||||
# ls-remote (the SHA itself is public; auth is incidental
|
||||
# here, used only to colocate the discovery in one step), then
|
||||
# `git push --dry-run <SHA>:refs/heads/staging`. Pushing the
|
||||
# current tip back to itself is "Everything up-to-date" with
|
||||
# exit 0 when auth succeeds. With a bad token Gitea returns
|
||||
# HTTP 401 in the smart-protocol handshake and git exits 128
|
||||
# with "Authentication failed".
|
||||
#
|
||||
# The dry-run never reaches Gitea's pre-receive hook (which
|
||||
# is where branch-protection authz runs), so this probe does
|
||||
# not validate failure mode C. That's intentional —
|
||||
# branch-protection-drift.yml owns authz monitoring; this
|
||||
# canary owns auth.
|
||||
env:
|
||||
# Don't hang waiting for password prompt if auth fails on a
|
||||
# terminal-attached run. (In Actions there's no terminal,
|
||||
# but the env-var hardens against an interactive runner
|
||||
# config.)
|
||||
GIT_TERMINAL_PROMPT: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Token is in $AUTO_SYNC_TOKEN (job-level env). Compose the
|
||||
# URL as a local var that's never echoed.
|
||||
url="https://oauth2:${AUTO_SYNC_TOKEN}@${GITEA_HOST}/${REPO_PATH}"
|
||||
|
||||
# Step a: read current staging SHA. ~1KB; auth-gated only
|
||||
# on private repos but always works on public — used here
|
||||
# only to discover the SHA, not to validate auth.
|
||||
staging_ref=$(timeout 30s git ls-remote --refs "$url" refs/heads/staging 2>&1) || {
|
||||
redacted=$(echo "$staging_ref" | sed -E "s|oauth2:[^@]+@|oauth2:<redacted>@|g")
|
||||
echo "::error::ls-remote against staging failed (network/DNS issue):" >&2
|
||||
echo "$redacted" >&2
|
||||
exit 1
|
||||
}
|
||||
if ! echo "$staging_ref" | grep -qE '^[0-9a-f]{40}[[:space:]]+refs/heads/staging$'; then
|
||||
echo "::error::ls-remote returned unexpected shape:" >&2
|
||||
echo "$staging_ref" | sed -E "s|oauth2:[^@]+@|oauth2:<redacted>@|g" >&2
|
||||
exit 1
|
||||
fi
|
||||
staging_sha=$(echo "$staging_ref" | awk '{print $1}')
|
||||
|
||||
# Step b: spin up an ephemeral local repo. `git push` always
|
||||
# requires a local repo even when pushing a remote SHA that
|
||||
# isn't in the local object DB (the protocol negotiates and
|
||||
# discovers we don't need to send any objects). We don't use
|
||||
# `actions/checkout` for this — it would clone the whole
|
||||
# repo (~hundreds of MB) for what's essentially `git init`.
|
||||
tmp_repo="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_repo"' EXIT
|
||||
git -C "$tmp_repo" init -q
|
||||
# Author config required for any git operation; values are
|
||||
# arbitrary because nothing gets committed here.
|
||||
git -C "$tmp_repo" config user.email canary@auto-sync.local
|
||||
git -C "$tmp_repo" config user.name auto-sync-canary
|
||||
|
||||
# Step c: dry-run push the current staging SHA back to
|
||||
# staging. NOP by construction — the remote tip equals the
|
||||
# SHA we're pushing, so "Everything up-to-date" is the
|
||||
# success path.
|
||||
#
|
||||
# Authentication is checked at the smart-protocol handshake,
|
||||
# BEFORE the dry-run can compute an empty diff. Bad token
|
||||
# → "Authentication failed", exit 128. Good token → exit 0.
|
||||
set +e
|
||||
push_out=$(timeout 30s git -C "$tmp_repo" push --dry-run "$url" "${staging_sha}:refs/heads/staging" 2>&1)
|
||||
push_rc=$?
|
||||
set -e
|
||||
|
||||
if [ "$push_rc" -ne 0 ]; then
|
||||
redacted=$(echo "$push_out" | sed -E "s|oauth2:[^@]+@|oauth2:<redacted>@|g")
|
||||
echo "::error::Token rotation suspected: git push --dry-run against staging failed via the AUTO_SYNC_TOKEN HTTPS auth path (exit $push_rc)." >&2
|
||||
echo "::error::This is the EXACT auth path that actions/checkout + git push use in auto-sync-main-to-staging.yml." >&2
|
||||
echo "::error::Likely cause: AUTO_SYNC_TOKEN was rotated/revoked on Gitea but the repo Actions secret was not updated. Runbook: see header." >&2
|
||||
echo "$redacted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "git HTTPS auth path: NOP push --dry-run to staging → ${staging_sha:0:8} ✓"
|
||||
|
||||
- name: Summarise canary result
|
||||
# Everything passed — surface a green summary. (Failures
|
||||
# already wrote ::error:: lines and exited above; if we got
|
||||
# here, all three probes passed.)
|
||||
run: |
|
||||
{
|
||||
echo "## Auto-sync canary: GREEN"
|
||||
echo ""
|
||||
echo "AUTO_SYNC_TOKEN is healthy:"
|
||||
echo "- Authenticates as \`${EXPECTED_PERSONA}\` ✓"
|
||||
echo "- Has \`read:repository\` scope on \`${REPO_PATH}\` ✓"
|
||||
echo "- Git HTTPS auth path: no-op dry-run push to \`refs/heads/staging\` succeeds ✓"
|
||||
echo ""
|
||||
echo "Auto-sync main → staging will succeed on the next push to main."
|
||||
echo "If this canary ever goes RED, see the runbook in this workflow's header."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -3,85 +3,138 @@ name: Auto-sync main → staging
|
||||
# Reflects every push to `main` back onto `staging` so the
|
||||
# staging-as-superset-of-main invariant holds.
|
||||
#
|
||||
# Background:
|
||||
# ============================================================
|
||||
# What this workflow does
|
||||
# ============================================================
|
||||
#
|
||||
# `auto-promote-staging.yml` advances main via `git merge --ff-only`
|
||||
# + `git push origin main` — that's a clean fast-forward, no merge
|
||||
# commit. But manual merges of `staging → main` PRs through the
|
||||
# GitHub UI / API create a merge commit on main that staging
|
||||
# doesn't have. The next `staging → main` PR then evaluates as
|
||||
# "BEHIND" because staging is missing that merge commit, requiring
|
||||
# a manual `gh pr update-branch` round-trip.
|
||||
# On every push to `main`:
|
||||
# 1. Checks if staging already contains main → no-op.
|
||||
# 2. Fetches both branches, merges main into staging in the
|
||||
# runner workspace (fast-forward if possible, else
|
||||
# `--no-ff` merge commit).
|
||||
# 3. Pushes staging directly to origin via the
|
||||
# `devops-engineer` persona's `AUTO_SYNC_TOKEN`.
|
||||
#
|
||||
# This happened twice on 2026-04-28 (PRs #2202, #2205, both manual
|
||||
# bridges). Each time the bridge needed update-branch + a re-CI
|
||||
# round before merging. Operationally annoying and avoidable.
|
||||
# Authoritative path: a single `git push origin staging` from
|
||||
# inside this workflow is the SSOT for advancing staging after
|
||||
# a main push. No PR, no merge queue, no human approval —
|
||||
# staging is mechanically maintained as a superset of main.
|
||||
#
|
||||
# Architecture:
|
||||
# `auto-promote-staging.yml` is the reverse-direction
|
||||
# counterpart (staging → main, gated on green CI). Together
|
||||
# they keep the staging-superset-of-main invariant tight.
|
||||
#
|
||||
# This repo's `staging` branch is protected by a `merge_queue`
|
||||
# ruleset (id 15500102) that blocks ALL direct pushes — no bypass
|
||||
# even for org admins or the GitHub Actions integration. Direct
|
||||
# `git push origin staging` returns GH013. So instead of pushing
|
||||
# directly, this workflow:
|
||||
# ============================================================
|
||||
# Why direct push (and not "open a PR")
|
||||
# ============================================================
|
||||
#
|
||||
# 1. Checks if main is already in staging's ancestry → no-op.
|
||||
# 2. Creates an `auto-sync/main-<sha>` branch from staging.
|
||||
# 3. Tries `git merge --ff-only origin/main` → if staging hasn't
|
||||
# diverged this is a clean ff.
|
||||
# 4. Otherwise `git merge --no-ff origin/main` to absorb main's
|
||||
# tip while keeping staging's history.
|
||||
# 5. Pushes the auto-sync branch.
|
||||
# 6. Opens a PR (base=staging, head=auto-sync/main-<sha>) and
|
||||
# enables auto-merge so the merge queue lands it.
|
||||
# Pre-2026-05-06 the canonical SCM was GitHub.com, where:
|
||||
# - The `staging` branch had a `merge_queue` ruleset that
|
||||
# blocked ALL direct pushes (no bypass even for org
|
||||
# admins or the GitHub Actions integration).
|
||||
# - Therefore this workflow opened a PR via `gh pr create`
|
||||
# and let auto-merge land it through the queue.
|
||||
#
|
||||
# This mirrors the path human PRs take through staging — same
|
||||
# rules, same gates, no special-case bypass.
|
||||
# Post-2026-05-06 the canonical SCM is Gitea
|
||||
# (`git.moleculesai.app/molecule-ai/molecule-core`). Gitea:
|
||||
# - Has no `merge_queue` concept.
|
||||
# - Allows direct push to protected branches via per-user
|
||||
# `push_whitelist_usernames` on the branch protection.
|
||||
# - Does not expose a GraphQL endpoint, so `gh pr create`
|
||||
# returns `HTTP 405 Method Not Allowed
|
||||
# (https://git.moleculesai.app/api/graphql)` — the
|
||||
# pre-suspension architecture cannot work on Gitea.
|
||||
#
|
||||
# Loop safety:
|
||||
# The molecule-ai/molecule-core staging branch protection
|
||||
# (verified via `GET /api/v1/repos/.../branch_protections`)
|
||||
# whitelists `devops-engineer` for direct push. So the
|
||||
# correct Gitea-shape architecture is: authenticate as
|
||||
# `devops-engineer`, merge locally, push staging directly.
|
||||
#
|
||||
# `GITHUB_TOKEN`-authored merges (including the merge queue's land
|
||||
# of the auto-sync PR) do NOT trigger downstream workflow runs
|
||||
# (GitHub Actions safety). So when the auto-sync PR lands on
|
||||
# staging, `auto-promote-staging.yml` is NOT triggered by that
|
||||
# push. The next developer push to staging triggers auto-promote
|
||||
# normally. No loop possible.
|
||||
# This is structurally simpler than the GitHub-era PR dance
|
||||
# and removes the dependence on `gh` CLI / GraphQL entirely.
|
||||
#
|
||||
# Concurrency:
|
||||
# ============================================================
|
||||
# Identity + token (anti-bot-ring per saved-memory
|
||||
# `feedback_per_agent_gitea_identity_default`)
|
||||
# ============================================================
|
||||
#
|
||||
# Two pushes to main in quick succession (e.g., manual UI merge
|
||||
# immediately followed by auto-promote-staging's ff-merge) could
|
||||
# otherwise open two overlapping auto-sync PRs. The concurrency
|
||||
# group serializes runs; the second waits for the first to exit.
|
||||
# (The first run exits after opening + auto-merge-queueing the PR,
|
||||
# not after the merge actually completes — so multiple PRs can be
|
||||
# open simultaneously, but the merge queue handles them serially.)
|
||||
# This workflow uses `secrets.AUTO_SYNC_TOKEN`, which is a
|
||||
# personal access token issued to the `devops-engineer`
|
||||
# persona on Gitea — NOT the founder PAT. The bot-ring
|
||||
# fingerprint that triggered the GitHub org suspension on
|
||||
# 2026-05-06 was characterised by founder PAT acting as CI
|
||||
# at machine speed; per-persona identities split the
|
||||
# attribution honestly.
|
||||
#
|
||||
# Token scope on Gitea: repo write. Push target restricted
|
||||
# to `staging` (this workflow is the only writer; main is
|
||||
# untouched). Compromise blast radius: bounded to staging
|
||||
# branch + this repo's read surface.
|
||||
#
|
||||
# Commits are authored by the persona email
|
||||
# `devops-engineer@agents.moleculesai.app` so commit history
|
||||
# reflects which automation produced the merge.
|
||||
#
|
||||
# ============================================================
|
||||
# Failure modes & operational notes
|
||||
# ============================================================
|
||||
#
|
||||
# A — staging has commits main doesn't, and the merge
|
||||
# conflicts:
|
||||
# - The `--no-ff` merge step exits non-zero. Workflow
|
||||
# fails red. Operator (devops-engineer or human)
|
||||
# resolves manually:
|
||||
# git fetch origin
|
||||
# git checkout staging
|
||||
# git merge --no-ff origin/main
|
||||
# # resolve conflicts
|
||||
# git push origin staging
|
||||
# - Step summary surfaces the conflict so the failed run
|
||||
# is self-explanatory.
|
||||
#
|
||||
# B — `AUTO_SYNC_TOKEN` rotated / wrong scope:
|
||||
# - `git push` step exits non-zero with `HTTP 401` /
|
||||
# `403`. Step summary surfaces the failed push.
|
||||
# - Re-issue the token from `~/.molecule-ai/personas/`
|
||||
# on the operator host and update the repo Actions
|
||||
# secret. Re-run the workflow.
|
||||
#
|
||||
# C — staging branch protection no longer whitelists
|
||||
# `devops-engineer`:
|
||||
# - `git push` exits non-zero with a Gitea protected-
|
||||
# branch rejection. Step summary surfaces it.
|
||||
# - Re-add `devops-engineer` to
|
||||
# `push_whitelist_usernames` on the staging
|
||||
# protection (Settings → Branches → staging).
|
||||
#
|
||||
# D — concurrent push to main while a sync is in flight:
|
||||
# - The `concurrency` group below serialises runs.
|
||||
# The second waits for the first; if main advances
|
||||
# again while we're syncing, the second run picks
|
||||
# up the new tip on its own fetch.
|
||||
#
|
||||
# ============================================================
|
||||
# Loop safety
|
||||
# ============================================================
|
||||
#
|
||||
# The push to staging from this workflow does NOT itself
|
||||
# fire a `push: branches: [main]` event (different branch),
|
||||
# so there's no risk of self-recursion. `auto-promote-staging.yml`
|
||||
# fires on `workflow_run` of CI etc. — it sees the new
|
||||
# staging tip on its next gate-completion event, NOT on this
|
||||
# push directly. No loop.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# workflow_dispatch lets:
|
||||
# 1. Operators manually backfill a missed sync (e.g. after a manual
|
||||
# UI merge that the runner missed).
|
||||
# 2. auto-promote-staging.yml's polling tail explicitly invoke us
|
||||
# after the promote PR lands. This is load-bearing: when the
|
||||
# merge queue lands a promote-PR merge, the resulting push to
|
||||
# `main` is "by GITHUB_TOKEN", and per GitHub's no-recursion
|
||||
# rule (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)
|
||||
# that push event does NOT fire any downstream workflows. The
|
||||
# `on: push` trigger above is silently dead for the very pattern
|
||||
# we exist to handle. Verified empirically 2026-05-02 against
|
||||
# SHA 76c604fb (PR #2437 staging→main): only ONE workflow fired
|
||||
# (publish-workspace-server-image, dispatched explicitly by
|
||||
# auto-promote's polling tail with an App token). Every other
|
||||
# `on: push: branches: [main]` workflow — including this one —
|
||||
# was suppressed. Until the underlying merge call moves to an
|
||||
# App token, an explicit dispatch is the only reliable path.
|
||||
# workflow_dispatch lets operators manually backfill a
|
||||
# missed sync (e.g. if AUTO_SYNC_TOKEN was rotated and a
|
||||
# main push slipped through while the secret was stale).
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: auto-sync-main-to-staging
|
||||
@@ -89,26 +142,25 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sync-staging:
|
||||
# ubuntu-latest matches every other workflow in this repo. The
|
||||
# earlier `[self-hosted, macos, arm64]` was a copy-paste artefact
|
||||
# from the molecule-controlplane repo (which IS private and uses a
|
||||
# Mac runner) — molecule-core has no Mac runner registered, so the
|
||||
# job sat unassigned whenever the trigger fired. Verified 2026-05-02:
|
||||
# this is the ONLY workflow in molecule-core/.github/workflows/ with
|
||||
# a non-ubuntu runs-on.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout staging
|
||||
- name: Checkout staging (with devops-engineer push token)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: staging
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# AUTO_SYNC_TOKEN authenticates as the
|
||||
# `devops-engineer` Gitea persona — the only
|
||||
# identity whitelisted for direct push to
|
||||
# staging. See header comment for context.
|
||||
token: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
# Per-persona identity, NOT founder PAT.
|
||||
# `feedback_per_agent_gitea_identity_default`.
|
||||
git config user.name "devops-engineer"
|
||||
git config user.email "devops-engineer@agents.moleculesai.app"
|
||||
|
||||
- name: Check if staging already contains main
|
||||
id: check
|
||||
@@ -118,7 +170,7 @@ jobs:
|
||||
if git merge-base --is-ancestor origin/main HEAD; then
|
||||
echo "needs_sync=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ✅ No-op"
|
||||
echo "## No-op"
|
||||
echo
|
||||
echo "staging already contains \`origin/main\` ($(git rev-parse --short=8 origin/main))."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -126,112 +178,78 @@ jobs:
|
||||
echo "needs_sync=true" >> "$GITHUB_OUTPUT"
|
||||
MAIN_SHORT=$(git rev-parse --short=8 origin/main)
|
||||
echo "main_short=${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=auto-sync/main-${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::staging is missing main's tip (${MAIN_SHORT}) — opening sync PR"
|
||||
echo "::notice::staging is missing main's tip (${MAIN_SHORT}) — merging in-runner and pushing"
|
||||
fi
|
||||
|
||||
- name: Create auto-sync branch + merge main
|
||||
- name: Merge main into staging (in-runner)
|
||||
if: steps.check.outputs.needs_sync == 'true'
|
||||
id: prep
|
||||
id: merge
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BRANCH="${{ steps.check.outputs.branch }}"
|
||||
|
||||
# If a previous auto-sync run already opened a branch for the
|
||||
# same main sha, prefer reusing it (idempotent behavior on
|
||||
# workflow restart). Force-update from latest staging anyway
|
||||
# so it absorbs any staging-side commits that landed since.
|
||||
git checkout -B "$BRANCH"
|
||||
|
||||
# Already on staging from checkout. Try fast-forward
|
||||
# first (cleanest history); fall back to merge commit
|
||||
# if staging has commits main doesn't.
|
||||
if git merge --ff-only origin/main; then
|
||||
echo "did_ff=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Fast-forwarded ${BRANCH} to origin/main"
|
||||
echo "::notice::Fast-forwarded staging to origin/main"
|
||||
else
|
||||
echo "did_ff=false" >> "$GITHUB_OUTPUT"
|
||||
if ! git merge --no-ff origin/main -m "chore: sync main → staging (auto)"; then
|
||||
if ! git merge --no-ff origin/main \
|
||||
-m "chore: sync main → staging (auto, ${{ steps.check.outputs.main_short }})"; then
|
||||
# Hygiene: leave the work tree clean before failing.
|
||||
git merge --abort || true
|
||||
{
|
||||
echo "## ❌ Conflict"
|
||||
echo "## Conflict"
|
||||
echo
|
||||
echo "Auto-merge \`main → staging\` failed with conflicts."
|
||||
echo "A human needs to resolve manually."
|
||||
echo "A human (or devops-engineer persona) needs to resolve manually:"
|
||||
echo
|
||||
echo '```'
|
||||
echo "git fetch origin"
|
||||
echo "git checkout staging"
|
||||
echo "git merge --no-ff origin/main"
|
||||
echo "# resolve conflicts"
|
||||
echo "git push origin staging"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Push auto-sync branch
|
||||
- name: Push staging to origin
|
||||
if: steps.check.outputs.needs_sync == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Force-with-lease so a concurrent auto-sync run can't
|
||||
# silently clobber an in-flight branch we just updated. If a
|
||||
# different writer touched the branch, we abort and the next
|
||||
# run picks up the latest state.
|
||||
git push --force-with-lease origin "${{ steps.check.outputs.branch }}"
|
||||
|
||||
- name: Open auto-sync PR + enable auto-merge
|
||||
if: steps.check.outputs.needs_sync == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BRANCH: ${{ steps.check.outputs.branch }}
|
||||
MAIN_SHORT: ${{ steps.check.outputs.main_short }}
|
||||
DID_FF: ${{ steps.prep.outputs.did_ff }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Find existing PR for this branch (idempotent on workflow
|
||||
# restart) before creating a new one.
|
||||
PR_NUM=$(gh pr list --head "$BRANCH" --base staging --state open --json number --jq '.[0].number // ""')
|
||||
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
# Body lives in a temp file to keep the multi-line content
|
||||
# out of the YAML block scalar (un-indented newlines inside
|
||||
# an inline shell string break YAML parsing).
|
||||
BODY_FILE=$(mktemp)
|
||||
if [ "$DID_FF" = "true" ]; then
|
||||
TITLE="chore: sync main → staging (auto, ff to ${MAIN_SHORT})"
|
||||
cat > "$BODY_FILE" <<EOFBODY
|
||||
Automated fast-forward of \`staging\` to \`origin/main\` (\`${MAIN_SHORT}\`). Staging has no in-flight commits that diverge from main. Merge queue lands this; no human action needed.
|
||||
|
||||
This PR is auto-generated by \`.github/workflows/auto-sync-main-to-staging.yml\` on every push to \`main\`. It exists because this repo's \`staging\` branch has a \`merge_queue\` ruleset that blocks direct pushes — even from the GitHub Actions integration.
|
||||
EOFBODY
|
||||
else
|
||||
TITLE="chore: sync main → staging (auto, merge ${MAIN_SHORT})"
|
||||
cat > "$BODY_FILE" <<EOFBODY
|
||||
Automated merge of \`origin/main\` (\`${MAIN_SHORT}\`) into \`staging\`. Staging has commits main doesn't, so this is a non-ff merge that absorbs main's tip. Merge queue lands this.
|
||||
|
||||
This PR is auto-generated by \`.github/workflows/auto-sync-main-to-staging.yml\` on every push to \`main\`.
|
||||
EOFBODY
|
||||
fi
|
||||
|
||||
# gh pr create prints the URL on stdout; extract the PR number.
|
||||
PR_URL=$(gh pr create \
|
||||
--base staging \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--body-file "$BODY_FILE")
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$' | tail -1)
|
||||
rm -f "$BODY_FILE"
|
||||
echo "::notice::Opened PR #${PR_NUM}"
|
||||
else
|
||||
echo "::notice::Re-using existing PR #${PR_NUM} for ${BRANCH}"
|
||||
fi
|
||||
|
||||
# Enable auto-merge — the merge queue picks it up once
|
||||
# required gates are green. Use --merge for merge commits
|
||||
# (matches the rest of this repo's PR convention).
|
||||
if ! gh pr merge "$PR_NUM" --auto --merge 2>&1; then
|
||||
echo "::warning::Failed to enable auto-merge on PR #${PR_NUM} — operator may need to merge manually."
|
||||
# Direct push to staging. devops-engineer persona is
|
||||
# whitelisted for direct push on the staging branch
|
||||
# protection (Settings → Branches → staging).
|
||||
#
|
||||
# No --force / --force-with-lease: a fast-forward or
|
||||
# legitimate merge commit on top of current staging
|
||||
# is the only thing we'd ever push. If origin/staging
|
||||
# advanced under us (concurrent merge), the push
|
||||
# legitimately rejects and the next run picks up the
|
||||
# new state.
|
||||
if ! git push origin staging; then
|
||||
{
|
||||
echo "## Push rejected"
|
||||
echo
|
||||
echo "Direct push to \`staging\` failed. Likely causes:"
|
||||
echo "- \`AUTO_SYNC_TOKEN\` rotated / wrong scope (HTTP 401/403)"
|
||||
echo "- \`devops-engineer\` no longer in"
|
||||
echo " \`push_whitelist_usernames\` on the staging"
|
||||
echo " branch protection (HTTP 422)"
|
||||
echo "- staging advanced concurrently — re-running this"
|
||||
echo " workflow on the new main tip will pick it up"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## ✅ Auto-sync PR opened"
|
||||
echo "## Auto-sync succeeded"
|
||||
echo
|
||||
echo "- Branch: \`$BRANCH\`"
|
||||
echo "- PR: #$PR_NUM"
|
||||
echo "- Strategy: $([ "$DID_FF" = "true" ] && echo "ff" || echo "merge commit")"
|
||||
echo
|
||||
echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
|
||||
echo "- staging advanced to: \`$(git rev-parse --short=8 HEAD)\`"
|
||||
echo "- main tip: \`${{ steps.check.outputs.main_short }}\`"
|
||||
echo "- Strategy: $([ "${{ steps.merge.outputs.did_ff }}" = "true" ] && echo "fast-forward" || echo "merge commit")"
|
||||
echo "- Pushed by: \`devops-engineer\` (per-agent persona, anti-bot-ring)"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -57,17 +57,42 @@ jobs:
|
||||
id: bump
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Gitea-shape token (act_runner forwards GITHUB_TOKEN as a
|
||||
# short-lived per-run secret with read access to this repo).
|
||||
# We hit `/api/v1/repos/.../pulls?state=closed` directly
|
||||
# because `gh pr list` calls Gitea's GraphQL endpoint, which
|
||||
# returns HTTP 405 (issue #75 / post-#66 sweep).
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
GITEA_API_URL: ${{ github.server_url }}/api/v1
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# The merged PR for this push commit. `gh pr list --search` finds
|
||||
# closed PRs whose merge commit matches; we take the first.
|
||||
PR=$(gh pr list --state merged --search "${{ github.sha }}" --json number,labels --jq '.[0]' 2>/dev/null || echo "")
|
||||
# Find the merged PR whose merge_commit_sha matches this push.
|
||||
# Gitea's `/repos/{owner}/{repo}/pulls?state=closed` returns
|
||||
# PRs sorted newest-first; we paginate up to 50 and jq-filter
|
||||
# on `merge_commit_sha == PUSH_SHA`. Bounded — auto-tag fires
|
||||
# per push to main, so the matching PR is always among the
|
||||
# most recent closures. 50 is comfortably more than the
|
||||
# ~10-20 staging→main promotes that close in any reasonable
|
||||
# window.
|
||||
set -euo pipefail
|
||||
PRS_JSON=$(curl --fail-with-body -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_API_URL}/repos/${REPO}/pulls?state=closed&sort=newest&limit=50" \
|
||||
2>/dev/null || echo "[]")
|
||||
PR=$(printf '%s' "$PRS_JSON" \
|
||||
| jq -c --arg sha "$PUSH_SHA" \
|
||||
'[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | .[0] // empty')
|
||||
if [ -z "$PR" ] || [ "$PR" = "null" ]; then
|
||||
echo "No merged PR found for ${{ github.sha }} — defaulting to patch bump."
|
||||
echo "No merged PR found for ${PUSH_SHA} — defaulting to patch bump."
|
||||
echo "kind=patch" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
LABELS=$(echo "$PR" | jq -r '.labels[].name')
|
||||
# Gitea returns labels under `.labels[].name`, same shape as
|
||||
# GitHub's REST. The previous `gh pr list --json number,labels`
|
||||
# output was identical; jq filter unchanged.
|
||||
LABELS=$(printf '%s' "$PR" | jq -r '.labels[]?.name // empty')
|
||||
if echo "$LABELS" | grep -qx 'release:major'; then
|
||||
echo "kind=major" >> "$GITHUB_OUTPUT"
|
||||
elif echo "$LABELS" | grep -qx 'release:minor'; then
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Block internal-flavored paths
|
||||
|
||||
# Hard CI gate. Internal content (positioning, competitive briefs, sales
|
||||
# playbooks, PMM/press drip, draft campaigns) lives in Molecule-AI/internal —
|
||||
# playbooks, PMM/press drip, draft campaigns) lives in molecule-ai/internal —
|
||||
# this public monorepo must never re-acquire those paths. CEO directive
|
||||
# 2026-04-23 after a fleet-wide audit found 79 internal files leaked here.
|
||||
#
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
echo "::error::Forbidden internal-flavored paths detected:"
|
||||
printf "$OFFENDING"
|
||||
echo ""
|
||||
echo "These paths belong in Molecule-AI/internal, not this public repo."
|
||||
echo "These paths belong in molecule-ai/internal, not this public repo."
|
||||
echo "See docs/internal-content-policy.md for canonical locations."
|
||||
echo ""
|
||||
echo "If your file is genuinely public-facing (e.g. a blog post"
|
||||
|
||||
@@ -19,6 +19,7 @@ on:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'tools/branch-protection/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/workflows/branch-protection-drift.yml'
|
||||
|
||||
permissions:
|
||||
@@ -79,3 +80,32 @@ jobs:
|
||||
# Repo-admin scope, needed for /branches/:b/protection.
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }}
|
||||
run: bash tools/branch-protection/drift_check.sh
|
||||
|
||||
# Self-test the parity script before running it on the real
|
||||
# workflows — pins the script's classification logic against
|
||||
# synthetic safe/unsafe/missing/unsafe-mix/matrix fixtures so a
|
||||
# regression in the script can't false-pass on the production
|
||||
# workflow audit. Cheap (~0.5s); always runs.
|
||||
- name: Self-test check-name parity script
|
||||
run: bash tools/branch-protection/test_check_name_parity.sh
|
||||
|
||||
# Check-name parity gate (#144 / saved memory
|
||||
# feedback_branch_protection_check_name_parity).
|
||||
#
|
||||
# drift_check.sh asserts the live branch protection matches what
|
||||
# apply.sh would set; check_name_parity.sh closes the orthogonal
|
||||
# gap: it asserts every required check name in apply.sh maps to a
|
||||
# workflow job whose "always emits this status" shape is intact.
|
||||
#
|
||||
# The two checks fail in different scenarios:
|
||||
#
|
||||
# - drift_check fails → live state was rewritten out-of-band
|
||||
# (UI click, manual PATCH).
|
||||
# - check_name_parity fails → an apply.sh required name has no
|
||||
# emitter, OR the emitting workflow has a top-level paths:
|
||||
# filter without per-step if-gates (the silent-block shape).
|
||||
#
|
||||
# Cheap (~1s); runs without the admin token because it only reads
|
||||
# apply.sh + .github/workflows/ from the checkout.
|
||||
- name: Run check-name parity gate
|
||||
run: bash tools/branch-protection/check_name_parity.sh
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
echo
|
||||
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
|
||||
echo "Phase 2 canary fleet has not been stood up yet —"
|
||||
echo "see [canary-tenants.md](https://github.com/Molecule-AI/molecule-controlplane/blob/main/docs/canary-tenants.md)."
|
||||
echo "see [canary-tenants.md](https://github.com/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
|
||||
echo
|
||||
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
run: go mod download
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli
|
||||
# CLI (molecli) moved to standalone repo: github.com/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go vet ./... || true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
# Strip the package-import prefix so we can match .coverage-allowlist.txt
|
||||
# entries written as paths relative to workspace-server/.
|
||||
# Handle both module paths: platform/workspace-server/... and platform/...
|
||||
rel=$(echo "$file" | sed 's|^github.com/Molecule-AI/molecule-monorepo/platform/workspace-server/||; s|^github.com/Molecule-AI/molecule-monorepo/platform/||')
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
|
||||
@@ -235,7 +235,13 @@ jobs:
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: needs.changes.outputs.canvas == 'true' && always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
||||
# v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not
|
||||
# currently supported on GHES`. Drop this pin when Gitea ships
|
||||
# the v4 protocol (tracked: post-Gitea-1.23 followup).
|
||||
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2
|
||||
with:
|
||||
name: canvas-coverage-${{ github.run_id }}
|
||||
path: canvas/coverage/
|
||||
@@ -243,8 +249,8 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
# MCP Server + SDK removed from CI — now in standalone repos:
|
||||
# - github.com/Molecule-AI/molecule-mcp-server (npm CI)
|
||||
# - github.com/Molecule-AI/molecule-sdk-python (PyPI CI)
|
||||
# - github.com/molecule-ai/molecule-mcp-server (npm CI)
|
||||
# - github.com/molecule-ai/molecule-sdk-python (PyPI CI)
|
||||
|
||||
# e2e-api job moved to .github/workflows/e2e-api.yml (issue #458).
|
||||
# It now has workflow-level concurrency (cancel-in-progress: false) so
|
||||
@@ -434,5 +440,5 @@ jobs:
|
||||
fi
|
||||
|
||||
# SDK + plugin validation moved to standalone repo:
|
||||
# github.com/Molecule-AI/molecule-sdk-python
|
||||
# github.com/molecule-ai/molecule-sdk-python
|
||||
|
||||
|
||||
@@ -1,36 +1,92 @@
|
||||
name: CodeQL
|
||||
|
||||
# Controls CodeQL scan triggers for this repo.
|
||||
# Stub workflow — CodeQL Action is structurally incompatible with Gitea
|
||||
# Actions (post-2026-05-06 SCM migration off GitHub).
|
||||
#
|
||||
# GitHub's "Code quality" default setup (the UI-configured one) is
|
||||
# hardcoded to only scan the default branch — on this repo that's
|
||||
# `staging`, so PRs promoting staging→main would otherwise never be
|
||||
# scanned. This workflow fills that gap by explicitly scanning both
|
||||
# branches on push and PR.
|
||||
# Why this is a stub, not a real CodeQL run:
|
||||
#
|
||||
# Runs on ubuntu-latest (GHA-hosted — public repo, free). GHAS is NOT
|
||||
# enabled on this repo, so results are not uploaded to the Security
|
||||
# tab — the scan fails the PR check on findings, and the SARIF is
|
||||
# kept as a workflow artifact for triage.
|
||||
# 1. github/codeql-action/init@v4 hits api.github.com endpoints
|
||||
# (CodeQL CLI bundle download + query-pack registry + telemetry)
|
||||
# that Gitea 1.22.x does NOT proxy. The act_runner has
|
||||
# GITHUB_SERVER_URL=https://git.moleculesai.app correctly set
|
||||
# (per saved memory feedback_act_runner_github_server_url and
|
||||
# /config.yaml on the operator host), but the Gitea API surface
|
||||
# simply does not implement the codeql-action bundle endpoints.
|
||||
# Observed in run 1d/3101 (2026-05-07): "::error::404 page not
|
||||
# found" inside the Initialize CodeQL step, before any analysis.
|
||||
#
|
||||
# 2. PR #35 attempted to mark `continue-on-error: true` at the JOB
|
||||
# level (correct YAML structure). Gitea 1.22.6 does NOT propagate
|
||||
# job-level continue-on-error to the commit-status API — every
|
||||
# matrix leg still posts `failure` to the status surface, which
|
||||
# keeps OVERALL=failure on every push to main + staging and
|
||||
# blocks visual auto-promote signals (#156).
|
||||
#
|
||||
# 3. Hongming policy decision (2026-05-07, task #156): CodeQL is
|
||||
# ADVISORY, not blocking, on Gitea Actions. We do not block PR
|
||||
# merge or staging→main promotion on CodeQL findings until we
|
||||
# have a Gitea-compatible static-analysis pipeline.
|
||||
#
|
||||
# What this stub preserves:
|
||||
#
|
||||
# - Workflow name `CodeQL` (referenced by auto-promote-staging.yml
|
||||
# line 67 as a workflow_run gate — must stay stable).
|
||||
# - Job name template `Analyze (${{ matrix.language }})` and the
|
||||
# 3-leg matrix (go, javascript-typescript, python). Branch
|
||||
# protection / required-check parity (#144) keys on these
|
||||
# exact context names.
|
||||
# - merge_group + push + pull_request + schedule triggers, so the
|
||||
# merge-queue check name still resolves (per saved memory
|
||||
# feedback_branch_protection_check_name_parity).
|
||||
#
|
||||
# Re-enabling real analysis (future work):
|
||||
#
|
||||
# - Option A: self-hosted Semgrep / OpenGrep via a custom action
|
||||
# that doesn't hit api.github.com. Tracked behind #156 follow-up.
|
||||
# - Option B: Sonatype Nexus IQ or similar, called from a step
|
||||
# that uses the Gitea-issued token only.
|
||||
# - Option C: re-host this workflow on a small GitHub mirror used
|
||||
# ONLY for SAST (push-mirrored from Gitea). Acceptable trade-off
|
||||
# if/when payment is restored on a non-suspended GitHub org —
|
||||
# but per saved memory feedback_no_single_source_of_truth, we
|
||||
# should design for multi-vendor backup, not GitHub-only SAST.
|
||||
#
|
||||
# Until one of those lands, this stub keeps commit-status green so
|
||||
# the auto-promote chain isn't permanently red on a tool we cannot
|
||||
# actually run.
|
||||
#
|
||||
# Security policy: ADVISORY. We accept the residual risk of un-scanned
|
||||
# pushes during this window. Compensating controls in place:
|
||||
# - secret-scan.yml runs on every push (active, blocks on hits)
|
||||
# - block-internal-paths.yml blocks forbidden file paths
|
||||
# - lint-curl-status-capture.yml catches one specific class of bug
|
||||
# - branch-protection-drift.yml + the merge_group required-checks
|
||||
# parity keep the gate surface stable
|
||||
# These are not equivalent to CodeQL coverage. Status of the
|
||||
# replacement plan is tracked in #156.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
# GitHub merge queue fires `merge_group` for the queue's pre-merge CI run.
|
||||
# Required so CodeQL Analyze checks get a real result on the queued
|
||||
# commit instead of a false-green. Event only fires once merge queue is
|
||||
# enabled on the target branch — safe to add unconditionally.
|
||||
# Required so the matrix legs emit a real result on the queued
|
||||
# commit instead of a false-green when merge queue is enabled.
|
||||
# Per saved memory feedback_branch_protection_check_name_parity:
|
||||
# path-filtered / matrix workflows MUST emit the protected name
|
||||
# via a job that always runs.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
schedule:
|
||||
# Weekly run picks up findings in code that hasn't been touched.
|
||||
# Weekly heartbeat. Cheap on a stub (the no-op job is ~5s) but
|
||||
# keeps the workflow visible in Gitea's Actions UI so the next
|
||||
# operator notices it's a stub instead of a missing surface.
|
||||
- cron: '30 1 * * 0'
|
||||
|
||||
# Workflow-level concurrency: only one CodeQL run per branch/PR at a time.
|
||||
# `cancel-in-progress: false` queues new runs so a quick follow-up push
|
||||
# doesn't nuke a 45-min analysis mid-flight.
|
||||
# Workflow-level concurrency: only one stub run per branch/PR at a
|
||||
# time. cancel-in-progress: false because a quick follow-up push
|
||||
# shouldn't kill an in-flight run — even though the stub is fast,
|
||||
# the contract should match a real CodeQL run for when we re-enable.
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -38,13 +94,17 @@ concurrency:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
# No security-events: write — we don't call the upload API.
|
||||
# No security-events: write — we don't call the upload API anyway,
|
||||
# GHAS isn't on Gitea.
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
# Job NAME shape is load-bearing — auto-promote-staging.yml +
|
||||
# branch protection both key on `Analyze (${{ matrix.language }})`.
|
||||
# Do NOT rename without coordinating both surfaces.
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 5
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -52,77 +112,25 @@ jobs:
|
||||
language: [go, javascript-typescript, python]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# Same reasoning as publish-workspace-server-image.yml — the Go
|
||||
# module's replace directive needs the plugin source so
|
||||
# CodeQL's "go build" phase can resolve.
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
# jq is pre-installed on ubuntu-latest — no setup step needed.
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# security-extended widens past the default to include the
|
||||
# full security-query set for a public SaaS surface.
|
||||
queries: security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# upload: never — GHAS isn't enabled on this repo, so the
|
||||
# upload API 403s. Write SARIF locally instead.
|
||||
upload: never
|
||||
output: sarif-results/${{ matrix.language }}
|
||||
|
||||
- name: Parse SARIF + fail on findings
|
||||
# The analyze step writes <database>.sarif into the output
|
||||
# directory — database name is the short CodeQL lang id, not
|
||||
# the matrix value (e.g. "javascript-typescript" →
|
||||
# javascript.sarif), so glob rather than hardcode.
|
||||
# Filter to error/warning severity: security-extended emits
|
||||
# "note" rows for informational findings we don't want to fail
|
||||
# the build over.
|
||||
# Single-step stub: log the policy decision + emit success.
|
||||
# Exit 0 explicitly so the commit-status API records `success`
|
||||
# for each of the three matrix legs.
|
||||
- name: CodeQL stub (advisory, non-blocking on Gitea)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dir="sarif-results/${{ matrix.language }}"
|
||||
sarif=$(ls "$dir"/*.sarif 2>/dev/null | head -1 || true)
|
||||
if [ -z "$sarif" ] || [ ! -f "$sarif" ]; then
|
||||
echo "::error::No SARIF file found under $dir"
|
||||
ls -la "$dir" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Parsing $sarif"
|
||||
count=$(jq '[.runs[].results[] | select(.level == "error" or .level == "warning")] | length' "$sarif")
|
||||
echo "CodeQL findings (error+warning) for ${{ matrix.language }}: $count"
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo "::error::CodeQL found $count issues. Details below; full SARIF in the artifact."
|
||||
jq -r '.runs[].results[] | select(.level == "error" or .level == "warning") | " - [\(.level)] \(.ruleId // "?"): \(.message.text // "(no message)") @ \(.locations[0].physicalLocation.artifactLocation.uri // "?"):\(.locations[0].physicalLocation.region.startLine // "?")"' "$sarif"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload SARIF artifact
|
||||
# Keep SARIF around on success + failure so triagers can diff.
|
||||
# 14-day retention — longer than default 3, short enough not
|
||||
# to bloat quota.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: codeql-sarif-${{ matrix.language }}
|
||||
path: sarif-results/${{ matrix.language }}/
|
||||
retention-days: 14
|
||||
cat <<EOF
|
||||
CodeQL is currently ADVISORY on Gitea Actions (post-2026-05-06).
|
||||
Language matrix leg: ${{ matrix.language }}
|
||||
Reason: github/codeql-action/init@v4 calls api.github.com
|
||||
bundle endpoints that Gitea 1.22.x does not implement.
|
||||
Observed: "::error::404 page not found" in the Init
|
||||
CodeQL step on every prior run.
|
||||
Policy: per Hongming decision 2026-05-07 (#156), CodeQL is
|
||||
non-blocking until a Gitea-compatible SAST pipeline
|
||||
lands. See workflow file header for replacement
|
||||
options + compensating controls.
|
||||
Status: emitting success so auto-promote isn't permanently
|
||||
red on a tool we cannot actually run today.
|
||||
EOF
|
||||
echo "::notice::CodeQL ${{ matrix.language }} — advisory stub, success."
|
||||
|
||||
@@ -12,6 +12,59 @@ name: E2E API Smoke Test
|
||||
# spending CI cycles. See the in-job comment on the `e2e-api` job for
|
||||
# why this is one job (not two-jobs-sharing-name) and the 2026-04-29
|
||||
# PR #2264 incident that drove the consolidation.
|
||||
#
|
||||
# Parallel-safety (Class B Hongming-owned CICD red sweep, 2026-05-08)
|
||||
# -------------------------------------------------------------------
|
||||
# Same substrate hazard as PR #98 (handlers-postgres-integration). Our
|
||||
# Gitea act_runner runs with `container.network: host` (operator host
|
||||
# `/opt/molecule/runners/config.yaml`), which means:
|
||||
#
|
||||
# * Two concurrent runs both try to bind their `-p 15432:5432` /
|
||||
# `-p 16379:6379` host ports — the second postgres/redis FATALs
|
||||
# with `Address in use` and `docker run` returns exit 125 with
|
||||
# `Conflict. The container name "/molecule-ci-postgres" is already
|
||||
# in use by container ...`. Verified in run a7/2727 on 2026-05-07.
|
||||
# * The fixed container names `molecule-ci-postgres` / `-redis` (the
|
||||
# pre-fix shape) collide on name AS WELL AS port. The cleanup-with-
|
||||
# `docker rm -f` at the start of the second job KILLS the first
|
||||
# job's still-running postgres/redis.
|
||||
#
|
||||
# Fix shape (mirrors PR #98's bridge-net pattern, adapted because
|
||||
# platform-server is a Go binary on the host, not a containerised
|
||||
# step):
|
||||
#
|
||||
# 1. Unique container names per run:
|
||||
# pg-e2e-api-${RUN_ID}-${RUN_ATTEMPT}
|
||||
# redis-e2e-api-${RUN_ID}-${RUN_ATTEMPT}
|
||||
# `${RUN_ID}-${RUN_ATTEMPT}` is unique even across reruns of the
|
||||
# same run_id.
|
||||
# 2. Ephemeral host port per run (`-p 0:5432`), then read the actual
|
||||
# bound port via `docker port` and export DATABASE_URL/REDIS_URL
|
||||
# pointing at it. No fixed host-port → no port collision.
|
||||
# 3. `127.0.0.1` (NOT `localhost`) in URLs — IPv6 first-resolve was
|
||||
# the original flake fixed in #92 and the script's still IPv6-
|
||||
# enabled.
|
||||
# 4. `if: always()` cleanup so containers don't leak when test steps
|
||||
# fail.
|
||||
#
|
||||
# Issue #94 items #2 + #3 (also fixed here):
|
||||
# * Pre-pull `alpine:latest` so the platform-server's provisioner
|
||||
# (`internal/handlers/container_files.go`) can stand up its
|
||||
# ephemeral token-write helper without a daemon.io round-trip.
|
||||
# * Create `molecule-monorepo-net` bridge network if missing so the
|
||||
# provisioner's container.HostConfig {NetworkMode: ...} attach
|
||||
# succeeds.
|
||||
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
|
||||
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
|
||||
# they DO come up. Timeouts are not the bottleneck; not bumped.
|
||||
#
|
||||
# Item explicitly NOT fixed here: failing test `Status back online`
|
||||
# fails because the platform's langgraph workspace template image
|
||||
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
|
||||
# 403 Forbidden post-2026-05-06 GitHub org suspension. That is a
|
||||
# template-registry resolution issue (ADR-002 / local-build mode) and
|
||||
# belongs in a separate change that touches workspace-server, not
|
||||
# this workflow file.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -78,11 +131,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
DATABASE_URL: postgres://dev:dev@localhost:15432/molecule?sslmode=disable
|
||||
REDIS_URL: redis://localhost:16379
|
||||
# Unique per-run container names so concurrent runs on the host-
|
||||
# network act_runner don't collide on name OR port.
|
||||
# `${RUN_ID}-${RUN_ATTEMPT}` stays unique across reruns of the
|
||||
# same run_id. PORT is set later (after docker port lookup) since
|
||||
# we let Docker assign an ephemeral host port.
|
||||
PG_CONTAINER: pg-e2e-api-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
REDIS_CONTAINER: redis-e2e-api-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
PORT: "8080"
|
||||
PG_CONTAINER: molecule-ci-postgres
|
||||
REDIS_CONTAINER: molecule-ci-redis
|
||||
steps:
|
||||
- name: No-op pass (paths filter excluded this commit)
|
||||
if: needs.detect-changes.outputs.api != 'true'
|
||||
@@ -97,11 +153,53 @@ jobs:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
- name: Pre-pull alpine + ensure provisioner network (Issue #94 items #2 + #3)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
# Provisioner uses alpine:latest for ephemeral token-write
|
||||
# containers (workspace-server/internal/handlers/container_files.go).
|
||||
# Pre-pull so the first provision in test_api.sh doesn't race
|
||||
# the daemon's pull cache. Idempotent — `docker pull` is a no-op
|
||||
# when the image is already present.
|
||||
docker pull alpine:latest >/dev/null
|
||||
# Provisioner attaches workspace containers to
|
||||
# molecule-monorepo-net (workspace-server/internal/provisioner/
|
||||
# provisioner.go::DefaultNetwork). The bridge already exists on
|
||||
# the operator host's docker daemon — `network create` is
|
||||
# idempotent via `|| true`.
|
||||
docker network create molecule-monorepo-net >/dev/null 2>&1 || true
|
||||
echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
|
||||
- name: Start Postgres (docker)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
# Defensive cleanup — only matches THIS run's container name,
|
||||
# so it cannot kill a sibling run's postgres. (Pre-fix the
|
||||
# name was static and this rm hit other runs' containers.)
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule -p 15432:5432 postgres:16
|
||||
# `-p 0:5432` requests an ephemeral host port; we read it back
|
||||
# below and export DATABASE_URL.
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
||||
-p 0:5432 postgres:16 >/dev/null
|
||||
# Resolve the host-side port assignment. `docker port` prints
|
||||
# `0.0.0.0:NNNN` (and on host-net runners may also print an
|
||||
# IPv6 line — take the first IPv4 line).
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
# Fallback: any first line. Some Docker versions print only
|
||||
# one line.
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
||||
fi
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $PG_CONTAINER"
|
||||
docker port "$PG_CONTAINER" 5432/tcp || true
|
||||
docker logs "$PG_CONTAINER" || true
|
||||
exit 1
|
||||
fi
|
||||
# 127.0.0.1 (NOT localhost) — IPv6 first-resolve flake (#92).
|
||||
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
|
||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
echo "Postgres host port: ${PG_PORT}"
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
|
||||
echo "Postgres ready after ${i}s"
|
||||
@@ -116,7 +214,20 @@ jobs:
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 16379:6379 redis:7
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
||||
fi
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
|
||||
docker port "$REDIS_CONTAINER" 6379/tcp || true
|
||||
docker logs "$REDIS_CONTAINER" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
echo "Redis host port: ${REDIS_PORT}"
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
|
||||
echo "Redis ready after ${i}s"
|
||||
@@ -135,13 +246,15 @@ jobs:
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
# DATABASE_URL + REDIS_URL exported by the start-postgres /
|
||||
# start-redis steps point at this run's per-run host ports.
|
||||
./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
- name: Wait for /health
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8080/health > /dev/null; then
|
||||
if curl -sf http://127.0.0.1:8080/health > /dev/null; then
|
||||
echo "Platform up after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
@@ -185,6 +298,9 @@ jobs:
|
||||
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
fi
|
||||
- name: Stop service containers
|
||||
# always() so containers don't leak when test steps fail. The
|
||||
# cleanup is best-effort: if the container is already gone
|
||||
# (e.g. concurrent rerun race), don't fail the job.
|
||||
if: always() && needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
|
||||
@@ -139,7 +139,11 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure() && needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||
# implement (see ci.yml upload step for the canonical error
|
||||
# cite). Drop this pin when Gitea ships the v4 protocol.
|
||||
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2
|
||||
with:
|
||||
name: playwright-report-staging
|
||||
path: canvas/playwright-report-staging/
|
||||
@@ -147,7 +151,8 @@ jobs:
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure() && needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility (see above).
|
||||
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2
|
||||
with:
|
||||
name: playwright-screenshots
|
||||
path: canvas/test-results/
|
||||
|
||||
@@ -121,8 +121,16 @@ jobs:
|
||||
# Per-migration result is logged so a failed migration that
|
||||
# SHOULD have been replayable surfaces in the CI log instead
|
||||
# of silently failing.
|
||||
# Apply both *.sql (legacy, lives next to its module) and
|
||||
# *.up.sql (newer up/down convention) in a single
|
||||
# lexicographically-sorted pass. Excluding *.down.sql so the
|
||||
# newest-naming-convention pairs don't undo themselves mid-run.
|
||||
# Pre-#149-followup this loop only globbed *.up.sql, which
|
||||
# silently skipped 001_workspaces.sql + 009_activity_logs.sql
|
||||
# — fine while no integration test depended on those tables,
|
||||
# not fine once a cross-table atomicity test came in.
|
||||
set +e
|
||||
for migration in migrations/*.up.sql; do
|
||||
for migration in $(ls migrations/*.sql 2>/dev/null | grep -v '\.down\.sql$' | sort); do
|
||||
if psql -h localhost -U postgres -d molecule -v ON_ERROR_STOP=1 \
|
||||
-f "$migration" >/dev/null 2>&1; then
|
||||
echo "✓ $(basename "$migration")"
|
||||
@@ -132,16 +140,19 @@ jobs:
|
||||
done
|
||||
set -e
|
||||
|
||||
# Sanity: the delegations table MUST exist for the integration
|
||||
# tests to be meaningful. Hard-fail if 049 didn't land — that
|
||||
# would be a real regression we want loud.
|
||||
if ! psql -h localhost -U postgres -d molecule -tA \
|
||||
-c "SELECT 1 FROM information_schema.tables WHERE table_name = 'delegations'" \
|
||||
| grep -q 1; then
|
||||
echo "::error::delegations table missing after migration replay — handler integration tests would be meaningless"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ delegations table present"
|
||||
# Sanity: the delegations + workspaces + activity_logs tables
|
||||
# MUST exist for the integration tests to be meaningful. Hard-
|
||||
# fail if any didn't land — that would be a real regression we
|
||||
# want loud.
|
||||
for tbl in delegations workspaces activity_logs pending_uploads; do
|
||||
if ! psql -h localhost -U postgres -d molecule -tA \
|
||||
-c "SELECT 1 FROM information_schema.tables WHERE table_name = '$tbl'" \
|
||||
| grep -q 1; then
|
||||
echo "::error::$tbl table missing after migration replay — handler integration tests would be meaningless"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $tbl table present"
|
||||
done
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true'
|
||||
name: Run integration tests
|
||||
|
||||
@@ -95,16 +95,57 @@ jobs:
|
||||
- if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# Dockerfile.tenant copies molecule-ai-plugin-github-app-auth/
|
||||
# at the build-context root (see workspace-server/Dockerfile.tenant
|
||||
# line 19). PLUGIN_REPO_PAT pattern matches publish-workspace-server-image.yml.
|
||||
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
|
||||
# the plugin was dropped + Dockerfile.tenant no longer COPYs it.
|
||||
|
||||
# Pre-clone manifest deps before docker compose builds the tenant
|
||||
# image (Task #173 followup — same pattern as
|
||||
# publish-workspace-server-image.yml's "Pre-clone manifest deps"
|
||||
# step).
|
||||
#
|
||||
# Why pre-clone here too: tests/harness/compose.yml builds tenant-alpha
|
||||
# and tenant-beta from workspace-server/Dockerfile.tenant with
|
||||
# context=../.. (repo root). That Dockerfile expects
|
||||
# .tenant-bundle-deps/{workspace-configs-templates,org-templates,plugins}
|
||||
# to be present at build context root (post-#173 it COPYs from there
|
||||
# instead of running an in-image clone — the in-image clone failed
|
||||
# with "could not read Username for https://git.moleculesai.app"
|
||||
# because there's no auth path inside the build sandbox).
|
||||
#
|
||||
# Without this step harness-replays fails before any replay runs,
|
||||
# with `failed to calculate checksum of ref ...
|
||||
# "/.tenant-bundle-deps/plugins": not found`. Caught by run #892
|
||||
# (main, 2026-05-07T20:28:53Z) and run #964 (staging — same
|
||||
# symptom, different root cause: staging still has the in-image
|
||||
# clone path, hits the auth error directly).
|
||||
#
|
||||
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
|
||||
# is the devops-engineer persona PAT, NOT the founder PAT (per
|
||||
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh
|
||||
# embeds it as basic-auth for the duration of the clones and strips
|
||||
# .git directories — the token never enters the resulting image.
|
||||
- name: Pre-clone manifest deps
|
||||
if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty — register the devops-engineer persona PAT in repo Actions secrets"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
# Sanity-check counts so a silent partial clone fails fast
|
||||
# instead of producing a half-empty image.
|
||||
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
|
||||
|
||||
- name: Install Python deps for replays
|
||||
# peer-discovery-404 (and future replays) eval Python against the
|
||||
|
||||
@@ -19,4 +19,4 @@ permissions:
|
||||
|
||||
jobs:
|
||||
disable-auto-merge-on-push:
|
||||
uses: Molecule-AI/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
|
||||
uses: molecule-ai/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
|
||||
|
||||
@@ -25,7 +25,7 @@ name: publish-runtime
|
||||
# 3. Publishes to PyPI via the PyPA Trusted Publisher action (OIDC).
|
||||
# No static API token is stored — PyPI verifies the workflow's
|
||||
# OIDC claim against the trusted-publisher config registered for
|
||||
# molecule-ai-workspace-runtime (Molecule-AI/molecule-core,
|
||||
# molecule-ai-workspace-runtime (molecule-ai/molecule-core,
|
||||
# publish-runtime.yml, environment pypi-publish).
|
||||
#
|
||||
# After publish: the 8 template repos pick up the new version on their
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Publish to PyPI (Trusted Publisher / OIDC)
|
||||
# PyPI side is configured: project molecule-ai-workspace-runtime →
|
||||
# publisher Molecule-AI/molecule-core, workflow publish-runtime.yml,
|
||||
# publisher molecule-ai/molecule-core, workflow publish-runtime.yml,
|
||||
# environment pypi-publish. The action mints a short-lived OIDC
|
||||
# token and exchanges it for a PyPI upload credential — no static
|
||||
# API token in this repo's secrets.
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
|
||||
FAILED=""
|
||||
for tpl in $TEMPLATES; do
|
||||
REPO="Molecule-AI/molecule-ai-workspace-template-$tpl"
|
||||
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
|
||||
STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \
|
||||
-X POST "https://api.github.com/repos/$REPO/dispatches" \
|
||||
-H "Authorization: Bearer $DISPATCH_TOKEN" \
|
||||
|
||||
@@ -37,6 +37,7 @@ on:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'manifest.json'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -60,8 +61,8 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -70,40 +71,91 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# workspace-server/Dockerfile expects
|
||||
# ./molecule-ai-plugin-github-app-auth at build-context root because
|
||||
# the Go module has a `replace` directive pointing at /plugin inside
|
||||
# the image. Pre-repo-split the plugin lived in the monorepo; the
|
||||
# 2026-04-18 restructure moved it out but didn't add this clone step
|
||||
# — which is why publish was failing after that restructure.
|
||||
#
|
||||
# Uses a fine-grained PAT (PLUGIN_REPO_PAT) because the plugin repo
|
||||
# is private and the default GITHUB_TOKEN is scoped to THIS repo.
|
||||
# The PAT needs Contents:Read on Molecule-AI/molecule-ai-plugin-
|
||||
# github-app-auth. Falls back to the default token for the (rare)
|
||||
# case where an operator made the plugin repo public.
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
|
||||
# plugin was dropped + workspace-server/Dockerfile no longer
|
||||
# COPYs it.
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
# ECR auth + buildx setup are now inline in each build step
|
||||
# below (Task #173, 2026-05-07).
|
||||
#
|
||||
# Why moved inline: aws-actions/configure-aws-credentials@v4 +
|
||||
# aws-actions/amazon-ecr-login@v2 + docker/setup-buildx-action
|
||||
# all left auth state in places that the actual `docker push`
|
||||
# couldn't see on Gitea Actions:
|
||||
# - The actions wrote to a step-scoped DOCKER_CONFIG path
|
||||
# that didn't survive into subsequent shell steps.
|
||||
# - Buildx couldn't bridge the runner container ↔
|
||||
# operator-host docker daemon auth gap (401 on the
|
||||
# docker-container driver, "no basic auth credentials"
|
||||
# with the action-driven login).
|
||||
#
|
||||
# Doing AWS+ECR auth inline (`aws ecr get-login-password |
|
||||
# docker login`) in the same shell step as `docker build` +
|
||||
# `docker push` is the operator-host manual approach, mapped
|
||||
# 1:1 into CI. Auth state is guaranteed to live in the env that
|
||||
# `docker push` actually runs from.
|
||||
#
|
||||
# Post-suspension target is the operator's ECR org
|
||||
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*),
|
||||
# which already hosts platform-tenant + workspace-template-* +
|
||||
# runner-base images. AWS creds come from the
|
||||
# AWS_ACCESS_KEY_ID/SECRET secrets bound to the molecule-cp
|
||||
# IAM user. Closes #161.
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Pre-clone manifest deps before docker build (Task #173 fix).
|
||||
#
|
||||
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on
|
||||
# Gitea (codex, crewai, deepagents, gemini-cli, langgraph) plus all
|
||||
# 7 org-template-* repos are private. The pre-fix Dockerfile.tenant
|
||||
# ran `git clone` inside an in-image stage, which had no auth path
|
||||
# — every CI build failed with "fatal: could not read Username for
|
||||
# https://git.moleculesai.app". For weeks, every workspace-server
|
||||
# rebuild required a manual operator-host push. Now we clone in the
|
||||
# trusted CI context (where AUTO_SYNC_TOKEN is naturally available)
|
||||
# and Dockerfile.tenant just COPYs from .tenant-bundle-deps/.
|
||||
#
|
||||
# Token shape: AUTO_SYNC_TOKEN is the devops-engineer persona PAT
|
||||
# (see /etc/molecule-bootstrap/agent-secrets.env). Per saved memory
|
||||
# `feedback_per_agent_gitea_identity_default`, every CI surface uses
|
||||
# a per-persona token, never the founder PAT. clone-manifest.sh
|
||||
# embeds it as basic-auth (oauth2:<token>) for the duration of the
|
||||
# clones, then strips .git directories — the token never enters
|
||||
# the resulting image.
|
||||
#
|
||||
# Idempotent: if a re-run finds populated dirs, clone-manifest.sh
|
||||
# skips them; safe to retrigger via path-filter or workflow_dispatch.
|
||||
- name: Pre-clone manifest deps
|
||||
env:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty — register the devops-engineer persona PAT in repo Actions secrets"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
# Sanity-check counts so a silent partial clone fails fast
|
||||
# instead of producing a half-empty image.
|
||||
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
|
||||
# Counts are derived from manifest.json (9 ws / 7 org / 21
|
||||
# plugins as of 2026-05-07). If manifest.json grows but the
|
||||
# clone step regresses silently, the find above caps at the
|
||||
# actual disk state — but clone-manifest.sh's own EXPECTED vs
|
||||
# CLONED check (line ~95) is the authoritative fail-fast.
|
||||
|
||||
# Canary-gated release flow:
|
||||
# - This step always publishes :staging-<sha> + :staging-latest.
|
||||
# - On staging push, staging-CP picks up :staging-latest immediately
|
||||
@@ -129,58 +181,82 @@ jobs:
|
||||
# were running pre-RFC code. Adding the staging trigger above closes
|
||||
# that gap. Earlier 2026-04-24 incident: a static :staging-<sha> pin
|
||||
# drifted 10 days behind staging — same class of bug, different
|
||||
# mechanism.
|
||||
- name: Build & push platform image to GHCR (staging-<sha> + staging-latest)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./workspace-server/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }}
|
||||
${{ env.IMAGE_NAME }}:staging-latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# GIT_SHA bakes into the Go binary via -ldflags so /buildinfo
|
||||
# returns it at runtime — see Dockerfile + buildinfo/buildinfo.go.
|
||||
# This is the same value as the OCI revision label below; passing
|
||||
# it twice is intentional, the OCI label is for registry tooling
|
||||
# while /buildinfo is for the redeploy verification step.
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify
|
||||
# mechanism. ECR repo molecule-ai/platform created 2026-05-07.
|
||||
# Build + push platform image with plain `docker` (no buildx).
|
||||
# GIT_SHA bakes into the Go binary via -ldflags so /buildinfo
|
||||
# returns it at runtime — see Dockerfile + buildinfo/buildinfo.go.
|
||||
# The OCI revision label below carries the same value for registry
|
||||
# tooling; the duplication is intentional.
|
||||
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ECR auth in-step so config.json is populated in the same
|
||||
# shell env that runs `docker push`. ECR get-login-password
|
||||
# tokens last 12h, plenty for a single-step build+push.
|
||||
ECR_REGISTRY="${IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify" \
|
||||
--tag "${IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${IMAGE_NAME}:${TAG_LATEST}"
|
||||
|
||||
# Canvas uses same-origin fetches. The tenant Go platform
|
||||
# reverse-proxies /cp/* to the SaaS CP via its CP_UPSTREAM_URL
|
||||
# env; the tenant's /canvas/viewport, /approvals/pending,
|
||||
# /org/templates etc. live on the tenant platform itself.
|
||||
# Both legs share one origin (the tenant subdomain) so
|
||||
# PLATFORM_URL="" forces canvas to fetch paths as relative,
|
||||
# which land same-origin.
|
||||
#
|
||||
# Self-hosted / private-label deployments override this at
|
||||
# build time with a specific backend (e.g. local dev:
|
||||
# NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080).
|
||||
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Re-login: the platform-image step's docker login wrote to
|
||||
# the same config.json, so this is technically redundant — but
|
||||
# making each push step self-contained keeps the workflow
|
||||
# robust to step reordering / future extraction.
|
||||
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile.tenant \
|
||||
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
|
||||
- name: Build & push tenant image to GHCR (staging-<sha> + staging-latest)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./workspace-server/Dockerfile.tenant
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TENANT_IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }}
|
||||
${{ env.TENANT_IMAGE_NAME }}:staging-latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# Canvas uses same-origin fetches. The tenant Go platform
|
||||
# reverse-proxies /cp/* to the SaaS CP via its CP_UPSTREAM_URL
|
||||
# env; the tenant's /canvas/viewport, /approvals/pending,
|
||||
# /org/templates etc. live on the tenant platform itself.
|
||||
# Both legs share one origin (the tenant subdomain) so
|
||||
# PLATFORM_URL="" forces canvas to fetch paths as relative,
|
||||
# which land same-origin.
|
||||
#
|
||||
# Self-hosted / private-label deployments override this at
|
||||
# build time with a specific backend (e.g. local dev:
|
||||
# NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080).
|
||||
build-args: |
|
||||
NEXT_PUBLIC_PLATFORM_URL=
|
||||
GIT_SHA=${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify
|
||||
|
||||
@@ -9,7 +9,7 @@ name: redeploy-tenants-on-main
|
||||
#
|
||||
# This workflow closes the gap by calling the control-plane admin
|
||||
# endpoint that performs a canary-first, batched, health-gated rolling
|
||||
# redeploy across every live tenant. Implemented in Molecule-AI/
|
||||
# redeploy across every live tenant. Implemented in molecule-ai/
|
||||
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
|
||||
# (feat/tenant-auto-redeploy, landing alongside this workflow).
|
||||
#
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
|
||||
# Molecule-AI/molecule-core, matching the staging/prod CP's
|
||||
# molecule-ai/molecule-core, matching the staging/prod CP's
|
||||
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
|
||||
# repo's secrets for CI.
|
||||
env:
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Call staging-CP redeploy-fleet
|
||||
# CP_STAGING_ADMIN_API_TOKEN must be set as a repo/org secret
|
||||
# on Molecule-AI/molecule-core, matching staging-CP's
|
||||
# on molecule-ai/molecule-core, matching staging-CP's
|
||||
# CP_ADMIN_API_TOKEN env var (visible in Railway controlplane
|
||||
# / staging environment). Stored separately from the prod
|
||||
# CP_ADMIN_API_TOKEN so a leak of one doesn't auth the other.
|
||||
|
||||
@@ -1,16 +1,99 @@
|
||||
name: Retarget main PRs to staging
|
||||
|
||||
# Mechanical enforcement of SHARED_RULES rule 8 ("Staging-first workflow, no
|
||||
# exceptions"). When a bot opens a PR against main, retarget it to staging
|
||||
# automatically and leave an explanatory comment. Human CEO-authored PRs (the
|
||||
# staging→main promotion PR, etc.) are left alone — they're the authorised
|
||||
# exception to the rule.
|
||||
# Mechanical enforcement of SHARED_RULES rule 8 ("Staging-first
|
||||
# workflow, no exceptions"). When a bot opens a PR against `main`,
|
||||
# retarget it to `staging` automatically and leave an explanatory
|
||||
# comment. Human / CEO-authored PRs (the staging→main promotion
|
||||
# PRs, etc.) are left alone — they're the authorised exception
|
||||
# to the rule.
|
||||
#
|
||||
# Why an Action instead of only a prompt rule: prompt rules depend on every
|
||||
# role's system-prompt.md staying in sync. Today 5 of 8 engineer roles
|
||||
# (core-be, core-fe, app-fe, app-qa, devops-engineer) don't have the
|
||||
# staging-first section — the bot keeps opening PRs to main. An Action
|
||||
# enforces the invariant regardless of prompt drift.
|
||||
# ============================================================
|
||||
# What this workflow does
|
||||
# ============================================================
|
||||
#
|
||||
# On `pull_request_target` opened/reopened against `main`:
|
||||
# 1. If the PR head is `staging`, skip (the auto-promote PRs
|
||||
# MUST stay base=main).
|
||||
# 2. If the PR author is a bot, retarget the PR base to
|
||||
# `staging` via Gitea REST `PATCH /pulls/{N}` body
|
||||
# `{"base":"staging"}`.
|
||||
# 3. If the retarget returns 422 "pull request already exists
|
||||
# for base branch 'staging'" (issue #1884 case: another PR
|
||||
# on the same head already targets staging), close the
|
||||
# now-redundant main-PR via Gitea REST instead of failing
|
||||
# red.
|
||||
# 4. Post an explainer comment on the retargeted PR via
|
||||
# Gitea REST `POST /issues/{N}/comments`.
|
||||
#
|
||||
# ============================================================
|
||||
# Why Gitea REST (and not `gh api / gh pr close / gh pr comment`)
|
||||
# ============================================================
|
||||
#
|
||||
# Pre-2026-05-06 this workflow used `gh api -X PATCH "repos/{owner}/{repo}/pulls/{N}" -f base=staging`
|
||||
# plus `gh pr close` and `gh pr comment`. After the GitHub→Gitea
|
||||
# cutover those calls fail because:
|
||||
#
|
||||
# - `gh` CLI defaults to `api.github.com`. Even with `GH_HOST`
|
||||
# pointing at Gitea, `gh pr close / comment` route through
|
||||
# GraphQL (`/api/graphql`) which Gitea does not expose.
|
||||
# Empirical: every `gh pr *` call returns
|
||||
# `HTTP 405 Method Not Allowed (https://git.moleculesai.app/api/graphql)`
|
||||
# — same root cause as #65 (auto-sync, fixed in PR #66) and
|
||||
# #73/#195 (auto-promote, fixed in PR #78).
|
||||
# - `gh api -X PATCH /pulls/{N}` happens to use a REST path
|
||||
# that Gitea also has, but the `gh` host-resolution layer
|
||||
# and pagination/retry logic don't always hit Gitea cleanly,
|
||||
# and the cost of switching to direct `curl` is one extra
|
||||
# line of code.
|
||||
#
|
||||
# So this workflow uses direct `curl` calls to Gitea REST. No
|
||||
# `gh` CLI dependency, no GraphQL, no flaky host-resolution.
|
||||
#
|
||||
# ============================================================
|
||||
# Identity + token (anti-bot-ring per saved-memory
|
||||
# `feedback_per_agent_gitea_identity_default`)
|
||||
# ============================================================
|
||||
#
|
||||
# Pre-fix this workflow used the per-job ephemeral
|
||||
# `secrets.GITHUB_TOKEN`. On Gitea Actions that token has
|
||||
# narrow scope and unpredictable cross-PR write capability.
|
||||
#
|
||||
# Post-fix: `secrets.AUTO_SYNC_TOKEN` (the `devops-engineer`
|
||||
# Gitea persona). Same persona used by `auto-sync-main-to-staging.yml`
|
||||
# (PR #66) and `auto-promote-staging.yml` (PR #78). Token scope:
|
||||
# `push: true` repo write, sufficient for PR-edit + close + comment.
|
||||
#
|
||||
# Why this token does NOT need branch-protection bypass:
|
||||
# patching a PR's base ref is a PR-level operation that does not
|
||||
# require push perms on either branch (the PR's own commits stay
|
||||
# put; only the metadata changes).
|
||||
#
|
||||
# ============================================================
|
||||
# Failure modes & operational notes
|
||||
# ============================================================
|
||||
#
|
||||
# A — PATCH base→staging returns 422 "pull request already exists"
|
||||
# (issue #1884 case):
|
||||
# - Detected by string-match on response body. Workflow
|
||||
# falls through to closing the now-redundant main-PR
|
||||
# (Gitea REST `PATCH /pulls/{N}` with `state: closed`)
|
||||
# and posts an explanation comment. Step summary surfaces.
|
||||
#
|
||||
# B — `AUTO_SYNC_TOKEN` rotated / wrong scope:
|
||||
# - First REST call returns 401/403. Step summary surfaces.
|
||||
# Re-issue token from `~/.molecule-ai/personas/` on the
|
||||
# operator host and update repo Actions secret.
|
||||
#
|
||||
# C — PR was deleted between trigger and run:
|
||||
# - REST call returns 404. Workflow exits 0 with a notice
|
||||
# (the rule was already enforced or the PR is gone).
|
||||
#
|
||||
# D — author is not actually a bot but the filter mis-fires:
|
||||
# - Filter is conservative: only triggers on
|
||||
# `user.type == 'Bot'`, `login` ends with `[bot]`, or
|
||||
# known bot logins (`molecule-ai[bot]`, `app/molecule-ai`).
|
||||
# Human PRs slip through unaffected. If a NEW bot login
|
||||
# starts shipping main-PRs, add it to the filter.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -24,16 +107,16 @@ jobs:
|
||||
retarget:
|
||||
name: Retarget to staging
|
||||
runs-on: ubuntu-latest
|
||||
# Only fire for bot-authored PRs. Human CEO PRs (staging→main promotion)
|
||||
# are intentional and pass through.
|
||||
# Only fire for bot-authored PRs. Human CEO PRs (staging→main
|
||||
# promotion) are intentional and pass through.
|
||||
#
|
||||
# Head-ref guard: never retarget a PR whose head IS `staging` — those
|
||||
# are the auto-promote staging→main PRs (opened by molecule-ai[bot]
|
||||
# since #2586 switched to an App token, which now passes the bot
|
||||
# filter below). Retargeting head=staging onto base=staging fails
|
||||
# with HTTP 422 "no new commits between base 'staging' and head
|
||||
# 'staging'", which used to surface as a noisy red workflow run on
|
||||
# every auto-promote (caught 2026-05-03 on PR #2588).
|
||||
# Head-ref guard: never retarget a PR whose head IS `staging`
|
||||
# — those are the auto-promote staging→main PRs (opened by
|
||||
# `devops-engineer` since PR #78 / #195 fix). Retargeting
|
||||
# head=staging onto base=staging fails with HTTP 422 "no new
|
||||
# commits between base 'staging' and head 'staging'", which
|
||||
# would surface as a noisy red workflow run on every
|
||||
# auto-promote (caught 2026-05-03 on the GitHub-era PR #2588).
|
||||
if: >-
|
||||
github.event.pull_request.head.ref != 'staging'
|
||||
&& (
|
||||
@@ -41,65 +124,153 @@ jobs:
|
||||
|| endsWith(github.event.pull_request.user.login, '[bot]')
|
||||
|| github.event.pull_request.user.login == 'app/molecule-ai'
|
||||
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
|
||||
|| github.event.pull_request.user.login == 'devops-engineer'
|
||||
)
|
||||
steps:
|
||||
- name: Retarget PR base to staging
|
||||
- name: Retarget PR base to staging via Gitea REST
|
||||
id: retarget
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
# Issue #1884: when the bot opens a PR against main and there's
|
||||
# already another PR on the same head branch targeting staging,
|
||||
# GitHub's PATCH /pulls returns 422 with
|
||||
# "A pull request already exists for base branch 'staging' …".
|
||||
# The retarget can't proceed — but the right response is to
|
||||
# close the now-redundant main-PR, not to fail the workflow
|
||||
# noisily. Detect that specific 422 and close instead.
|
||||
# Issue #1884 case: when the bot opens a PR against main
|
||||
# and there's already another PR on the same head branch
|
||||
# targeting staging, Gitea's PATCH returns 422 with a
|
||||
# body mentioning "pull request already exists for base
|
||||
# branch 'staging'" (the Gitea message wording is
|
||||
# slightly different from GitHub's; the substring match
|
||||
# below covers both for forward/back compat).
|
||||
# The retarget can't proceed — but the right response is
|
||||
# to close the now-redundant main-PR, not to fail the
|
||||
# workflow noisily. Detect that specific 422 and close
|
||||
# instead.
|
||||
run: |
|
||||
set +e
|
||||
set -euo pipefail
|
||||
|
||||
API="${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
|
||||
|
||||
echo "Retargeting PR #${PR_NUMBER} (author: ${PR_AUTHOR}) from main → staging"
|
||||
PATCH_OUTPUT=$(gh api -X PATCH \
|
||||
"repos/${{ github.repository }}/pulls/${PR_NUMBER}" \
|
||||
-f base=staging \
|
||||
--jq '.base.ref' 2>&1)
|
||||
PATCH_EXIT=$?
|
||||
|
||||
# Curl-status-capture pattern per `feedback_curl_status_capture_pollution`:
|
||||
# http_code via -w to its own scalar, body to a tempfile, set +e/-e
|
||||
# bracket so curl's non-zero-on-4xx doesn't pollute the script's exit chain.
|
||||
BODY_FILE=$(mktemp)
|
||||
REQ='{"base":"staging"}'
|
||||
|
||||
set +e
|
||||
STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
|
||||
-X PATCH -d "${REQ}" \
|
||||
-o "${BODY_FILE}" -w "%{http_code}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
CURL_RC=$?
|
||||
set -e
|
||||
if [ "$PATCH_EXIT" -eq 0 ]; then
|
||||
echo "::notice::Retargeted PR #${PR_NUMBER} → staging"
|
||||
echo "outcome=retargeted" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
if [ "${CURL_RC}" -ne 0 ]; then
|
||||
echo "::error::curl PATCH failed (rc=${CURL_RC})"
|
||||
rm -f "${BODY_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${STATUS}" = "201" ] || [ "${STATUS}" = "200" ]; then
|
||||
NEW_BASE=$(jq -r '.base.ref // "?"' < "${BODY_FILE}")
|
||||
rm -f "${BODY_FILE}"
|
||||
if [ "${NEW_BASE}" = "staging" ]; then
|
||||
echo "::notice::Retargeted PR #${PR_NUMBER} → staging"
|
||||
echo "outcome=retargeted" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::PATCH returned ${STATUS} but base.ref is '${NEW_BASE}', not 'staging'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Specifically match the 422 duplicate-base/head error so
|
||||
# any OTHER PATCH failure (auth, deleted PR, etc.) still
|
||||
# surfaces as a real workflow failure.
|
||||
if echo "$PATCH_OUTPUT" | grep -q "pull request already exists for base branch 'staging'"; then
|
||||
BODY=$(cat "${BODY_FILE}" || true)
|
||||
rm -f "${BODY_FILE}"
|
||||
|
||||
if [ "${STATUS}" = "422" ] && echo "${BODY}" | grep -qE "(pull request already exists for base branch 'staging'|already exists.*base.*staging)"; then
|
||||
echo "::notice::PR #${PR_NUMBER}: duplicate target-staging PR exists on same head — closing this main-PR as redundant."
|
||||
gh pr close "$PR_NUMBER" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--comment "[retarget-bot] Closing — another PR on the same head branch already targets \`staging\`. This PR is redundant. See issue #1884 for the rationale."
|
||||
echo "outcome=closed-as-duplicate" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
# Close the now-redundant main-PR via Gitea REST
|
||||
# (PATCH state=closed). Post comment explaining
|
||||
# rationale BEFORE close so the comment lands on the
|
||||
# PR (commenting on a closed PR works on Gitea, but
|
||||
# historically caused notification ordering surprises).
|
||||
|
||||
CLOSE_BODY_FILE=$(mktemp)
|
||||
CMT_REQ=$(jq -n '{body:"[retarget-bot] Closing — another PR on the same head branch already targets `staging`. This PR is redundant. See issue #1884 for the rationale."}')
|
||||
set +e
|
||||
CMT_STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
|
||||
-X POST -d "${CMT_REQ}" \
|
||||
-o "${CLOSE_BODY_FILE}" -w "%{http_code}" \
|
||||
"${API}/issues/${PR_NUMBER}/comments")
|
||||
set -e
|
||||
if [ "${CMT_STATUS}" != "201" ]; then
|
||||
echo "::warning::dup-close comment POST returned ${CMT_STATUS}; continuing to close anyway"
|
||||
cat "${CLOSE_BODY_FILE}" | head -c 300 || true
|
||||
fi
|
||||
rm -f "${CLOSE_BODY_FILE}"
|
||||
|
||||
CLOSE_REQ='{"state":"closed"}'
|
||||
CLOSE_RESP=$(mktemp)
|
||||
set +e
|
||||
CL_STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
|
||||
-X PATCH -d "${CLOSE_REQ}" \
|
||||
-o "${CLOSE_RESP}" -w "%{http_code}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
set -e
|
||||
if [ "${CL_STATUS}" = "201" ] || [ "${CL_STATUS}" = "200" ]; then
|
||||
echo "::notice::Closed PR #${PR_NUMBER} as redundant"
|
||||
echo "outcome=closed-as-duplicate" >> "$GITHUB_OUTPUT"
|
||||
rm -f "${CLOSE_RESP}"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Failed to close redundant PR: HTTP ${CL_STATUS}"
|
||||
cat "${CLOSE_RESP}" | head -c 300 || true
|
||||
rm -f "${CLOSE_RESP}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::error::Retarget PATCH failed and was NOT a duplicate-base error:"
|
||||
echo "$PATCH_OUTPUT" >&2
|
||||
|
||||
echo "::error::Retarget PATCH failed and was NOT a duplicate-base error: HTTP ${STATUS}"
|
||||
echo "${BODY}" | head -c 500 >&2
|
||||
exit 1
|
||||
|
||||
- name: Post explainer comment
|
||||
if: steps.retarget.outputs.outcome == 'retargeted'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--body "$(cat <<'BODY'
|
||||
[retarget-bot] This PR was opened against `main` and has been retargeted to `staging` automatically.
|
||||
set -euo pipefail
|
||||
|
||||
**Why:** per [SHARED_RULES rule 8](https://github.com/Molecule-AI/molecule-ai-org-template-molecule-dev/blob/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.
|
||||
API="${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
|
||||
|
||||
**What changed:** just the base branch — no code change. CI will re-run against `staging`. If you get merge conflicts, rebase on `staging`.
|
||||
# PR comments live on the issue endpoint in Gitea
|
||||
# (PRs ARE issues — same endpoint, different sub-resources
|
||||
# for diffs/files/etc.). The body uses jq to safely
|
||||
# encode the multi-line markdown without shell-quote
|
||||
# nightmares.
|
||||
REQ=$(jq -n '{body:"[retarget-bot] This PR was opened against `main` and has been retargeted to `staging` automatically.\n\n**Why:** per [SHARED_RULES rule 8](https://git.moleculesai.app/molecule-ai/molecule-ai-org-template-molecule-dev/src/branch/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.\n\n**What changed:** just the base branch — no code change. CI will re-run against `staging`. If you get merge conflicts, rebase on `staging`.\n\n**If this PR is the CEO`s staging→main promotion:** the Action skipped you (only bot-authored PRs are retargeted, head=staging is also exempted). If you see this comment on your CEO PR, that`s a bug — please tag @hongmingwang."}')
|
||||
|
||||
**If this PR is the CEO's staging→main promotion:** the Action skipped you (only bot-authored PRs are retargeted). If you see this comment on your CEO PR, that's a bug — please tag @HongmingWang-Rabbit.
|
||||
BODY
|
||||
)"
|
||||
BODY_FILE=$(mktemp)
|
||||
set +e
|
||||
STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
|
||||
-X POST -d "${REQ}" \
|
||||
-o "${BODY_FILE}" -w "%{http_code}" \
|
||||
"${API}/issues/${PR_NUMBER}/comments")
|
||||
set -e
|
||||
|
||||
if [ "${STATUS}" = "201" ]; then
|
||||
echo "::notice::Posted explainer comment on PR #${PR_NUMBER}"
|
||||
else
|
||||
echo "::warning::Failed to post explainer (HTTP ${STATUS}) — retarget itself succeeded"
|
||||
cat "${BODY_FILE}" | head -c 300 || true
|
||||
fi
|
||||
rm -f "${BODY_FILE}"
|
||||
|
||||
@@ -12,7 +12,7 @@ name: Secret scan
|
||||
#
|
||||
# jobs:
|
||||
# secret-scan:
|
||||
# uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||
# uses: molecule-ai/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||
#
|
||||
# Pin to @staging not @main — staging is the active default branch,
|
||||
# main lags via the staging-promotion workflow. Updates ride along
|
||||
|
||||
@@ -108,6 +108,14 @@ jobs:
|
||||
python3 > stale_slugs.txt <<'PY'
|
||||
import json, os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
# SSOT for this list lives in the controlplane Go code:
|
||||
# molecule-controlplane/internal/slugs/ephemeral.go
|
||||
# (var EphemeralPrefixes). The redeploy-fleet auto-rollout
|
||||
# also reads from there to SKIP these slugs — without that
|
||||
# filter, fleet redeploy SSM-failed in-flight E2E tenants
|
||||
# whose containers were still booting, breaking the test
|
||||
# that just spun them up (molecule-controlplane#493).
|
||||
# Update both files together.
|
||||
EPHEMERAL_PREFIXES = ("e2e-", "rt-e2e-")
|
||||
with open("orgs.json") as f:
|
||||
data = json.load(f)
|
||||
@@ -185,7 +193,47 @@ jobs:
|
||||
# sweeper is best-effort. Next hourly tick re-attempts. We
|
||||
# only fail loud at the safety-cap gate above.
|
||||
|
||||
- name: Sweep orphan tunnels
|
||||
# Stale-org cleanup deletes the org (which cascades to tunnel
|
||||
# delete inside the CP). But when that cascade fails partway —
|
||||
# CP transient 5xx after the org row is deleted but before the
|
||||
# CF tunnel delete completes — the tunnel persists with no
|
||||
# matching org row. The reconciler in internal/sweep flags this
|
||||
# as `cf_tunnel kind=orphan`, but nothing automatically reaps it.
|
||||
#
|
||||
# `/cp/admin/orphan-tunnels/cleanup` is the operator-triggered
|
||||
# reaper. Calling it here at the end of every sweep tick
|
||||
# converges the staging CF account to clean even when CP
|
||||
# cascades half-fail.
|
||||
#
|
||||
# PR #492 made the underlying DeleteTunnel actually check
|
||||
# status — pre-fix it silent-succeeded on CF code 1022
|
||||
# ("active connections"), so this step would have been a no-op
|
||||
# against stuck connectors. Post-fix the cleanup invokes
|
||||
# CleanupTunnelConnections + retry, which actually clears the
|
||||
# 1022 case. (#2987)
|
||||
#
|
||||
# Best-effort. Failure here doesn't fail the workflow — next
|
||||
# tick re-attempts. Errors flow to step output for ops review.
|
||||
if: env.DRY_RUN != 'true'
|
||||
run: |
|
||||
set +e
|
||||
curl -sS -o /tmp/cleanup_resp -w "%{http_code}" \
|
||||
--max-time 60 \
|
||||
-X POST "$MOLECULE_CP_URL/cp/admin/orphan-tunnels/cleanup" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" >/tmp/cleanup_code
|
||||
set -e
|
||||
http_code=$(cat /tmp/cleanup_code 2>/dev/null || echo "000")
|
||||
body=$(cat /tmp/cleanup_resp 2>/dev/null | head -c 500)
|
||||
if [ "$http_code" = "200" ]; then
|
||||
count=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('deleted_count', 0))" 2>/dev/null || echo "0")
|
||||
failed_n=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(len(d.get('failed') or {}))" 2>/dev/null || echo "0")
|
||||
echo "Orphan-tunnel sweep: deleted=$count failed=$failed_n"
|
||||
else
|
||||
echo "::warning::orphan-tunnels cleanup returned HTTP $http_code — body: $body"
|
||||
fi
|
||||
|
||||
- name: Dry-run summary
|
||||
if: env.DRY_RUN == 'true'
|
||||
run: |
|
||||
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s). Re-run with dry_run=false to actually delete."
|
||||
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s) AND triggered orphan-tunnels cleanup. Re-run with dry_run=false to actually delete."
|
||||
|
||||
@@ -131,6 +131,13 @@ backups/
|
||||
# Cloned by publish-workspace-server-image.yml so the Dockerfile's
|
||||
# replace-directive path resolves. Lives in its own repo.
|
||||
/molecule-ai-plugin-github-app-auth/
|
||||
# Tenant-image build context — populated by the workflow's
|
||||
# "Pre-clone manifest deps" step. Mirrors the public manifest, holds the
|
||||
# same content as the three /<>/ dirs above but namespaced under one
|
||||
# parent so the Docker build context is a single COPY-friendly tree.
|
||||
# Each entry is a transient working-dir, never source-of-truth, never
|
||||
# committed.
|
||||
/.tenant-bundle-deps/
|
||||
|
||||
# Internal-flavored content lives in Molecule-AI/internal — NEVER in this
|
||||
# public monorepo. Migrated 2026-04-23 (CEO directive). The CI workflow
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cookies, headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { AuthGate } from "@/components/AuthGate";
|
||||
import { CookieConsent } from "@/components/CookieConsent";
|
||||
import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal";
|
||||
import { ThemeProvider } from "@/lib/theme-provider";
|
||||
import {
|
||||
THEME_COOKIE,
|
||||
@@ -86,6 +87,12 @@ export default async function RootLayout({
|
||||
vercel preview URL, apex) pass through unchanged. */}
|
||||
<AuthGate>{children}</AuthGate>
|
||||
<CookieConsent />
|
||||
{/* Demo Mock #1: post-purchase success toast. Mounted at the
|
||||
layout level so it persists across page state transitions
|
||||
(loading → hydrated → error) without being unmounted and
|
||||
losing its open-state. Reads ?purchase_success=1 from the
|
||||
URL on first paint, then strips the param. */}
|
||||
<PurchaseSuccessModal />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { type Edge, MarkerType } from "@xyflow/react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import type { ActivityEntry } from "@/types/activity";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
@@ -11,9 +12,6 @@ import type { ActivityEntry } from "@/types/activity";
|
||||
/** 60-minute look-back window for delegation activity */
|
||||
export const A2A_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Polling interval — refresh edges every 60 seconds */
|
||||
export const A2A_POLL_MS = 60 * 1_000;
|
||||
|
||||
/** Threshold for "hot" edges: < 5 minutes → animated + violet stroke */
|
||||
export const A2A_HOT_MS = 5 * 60 * 1_000;
|
||||
|
||||
@@ -131,6 +129,20 @@ export function buildA2AEdges(
|
||||
* `a2aEdges`. Canvas.tsx merges these with topology edges and passes the
|
||||
* combined list to ReactFlow.
|
||||
*
|
||||
* Update shape (issue #61 Stage 2, replaces the 60s polling loop):
|
||||
* - On mount (when showA2AEdges): one HTTP fan-out per visible workspace
|
||||
* (delegation rows, 60-min window). Bootstraps the local row buffer.
|
||||
* - Steady state: subscribes to ACTIVITY_LOGGED via useSocketEvent.
|
||||
* Each delegation event from a visible workspace is appended to the
|
||||
* buffer; edges are re-derived via the existing buildA2AEdges helper.
|
||||
* - showA2AEdges toggle off: clears edges + buffer.
|
||||
* - Visible-ID-set change: re-bootstraps so a freshly-shown workspace
|
||||
* backfills its 60-min history (existing visibleIdsKey selector
|
||||
* behaviour preserved — that's the 2026-05-04 render-loop fix).
|
||||
*
|
||||
* No interval poll. The singleton ReconnectingSocket already owns
|
||||
* reconnect / backoff / health-check; useSocketEvent inherits those.
|
||||
*
|
||||
* Mount this inside CanvasInner (no ReactFlow hook dependency).
|
||||
*/
|
||||
export function A2ATopologyOverlay() {
|
||||
@@ -157,7 +169,9 @@ export function A2ATopologyOverlay() {
|
||||
// the symptom of this re-render storm.
|
||||
//
|
||||
// The fix is purely the dependency-stability change here; the fetch
|
||||
// logic is unchanged.
|
||||
// logic is unchanged. Post-#61 the polling-driven fetch is gone, but
|
||||
// the visibleIdsKey gate is still required so a peer-discovery write
|
||||
// doesn't trigger a wasteful re-bootstrap.
|
||||
const visibleIdsKey = useCanvasStore((s) =>
|
||||
s.nodes
|
||||
.filter((n) => !n.hidden)
|
||||
@@ -171,16 +185,42 @@ export function A2ATopologyOverlay() {
|
||||
[visibleIdsKey]
|
||||
);
|
||||
|
||||
// Fetch delegation activity for all visible workspaces and rebuild overlay edges.
|
||||
const fetchAndUpdate = useCallback(async () => {
|
||||
// Local rolling buffer of delegation rows. Pruned by A2A_WINDOW_MS on
|
||||
// each rebuild so a long-lived session doesn't accumulate unbounded
|
||||
// history. The buffer's high-water mark is approximately:
|
||||
// visibleIds.length × bootstrap-fetch-limit (500) + WS arrivals
|
||||
// Real-world ceiling: ~3000 entries at the 60-min boundary, all of
|
||||
// which buildA2AEdges aggregates into at most N² edges.
|
||||
const bufferRef = useRef<ActivityEntry[]>([]);
|
||||
// visibleIdsRef gives the WS handler the latest visible-ID set without
|
||||
// re-subscribing on every render. The bus listener is registered
|
||||
// exactly once per mount; subscriber-side filtering reads from this ref.
|
||||
const visibleIdsRef = useRef(visibleIds);
|
||||
visibleIdsRef.current = visibleIds;
|
||||
|
||||
// Re-derive overlay edges from the current buffer + push to store.
|
||||
// Prunes by A2A_WINDOW_MS first so memory stays bounded across long
|
||||
// sessions and the aggregation cost stays O(window-size).
|
||||
const recomputeAndPush = useCallback(() => {
|
||||
const cutoff = Date.now() - A2A_WINDOW_MS;
|
||||
bufferRef.current = bufferRef.current.filter(
|
||||
(r) => new Date(r.created_at).getTime() > cutoff
|
||||
);
|
||||
setA2AEdges(buildA2AEdges(bufferRef.current));
|
||||
}, [setA2AEdges]);
|
||||
|
||||
// Bootstrap fan-out — one HTTP per visible workspace. Replaces the
|
||||
// 60s polling loop entirely. Race-aware: any WS arrivals that landed
|
||||
// in the buffer DURING the fetch (between the await and resume) are
|
||||
// preserved by id-dedup-with-fetched-first ordering.
|
||||
const bootstrap = useCallback(async () => {
|
||||
if (visibleIds.length === 0) {
|
||||
bufferRef.current = [];
|
||||
setA2AEdges([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Fan-out — one request per visible workspace.
|
||||
// Per-request failures are swallowed so one broken workspace doesn't blank the overlay.
|
||||
const allRows = (
|
||||
const fetchedRows = (
|
||||
await Promise.all(
|
||||
visibleIds.map((id) =>
|
||||
api
|
||||
@@ -192,24 +232,76 @@ export function A2ATopologyOverlay() {
|
||||
)
|
||||
).flat();
|
||||
|
||||
setA2AEdges(buildA2AEdges(allRows));
|
||||
// Merge: fetched rows first, then any in-flight WS arrivals that
|
||||
// accumulated during the await. Dedup by id so rows that appear
|
||||
// in both paths are not double-counted in the aggregation.
|
||||
const merged = [...fetchedRows, ...bufferRef.current];
|
||||
const seen = new Set<string>();
|
||||
bufferRef.current = merged.filter((r) => {
|
||||
if (seen.has(r.id)) return false;
|
||||
seen.add(r.id);
|
||||
return true;
|
||||
});
|
||||
recomputeAndPush();
|
||||
} catch {
|
||||
// Overlay failure is non-critical — canvas remains functional
|
||||
}
|
||||
}, [visibleIds, setA2AEdges]);
|
||||
}, [visibleIds, setA2AEdges, recomputeAndPush]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showA2AEdges) {
|
||||
// Clear edges immediately when toggled off
|
||||
// Clear edges + buffer immediately when toggled off
|
||||
bufferRef.current = [];
|
||||
setA2AEdges([]);
|
||||
return;
|
||||
}
|
||||
void bootstrap();
|
||||
}, [showA2AEdges, bootstrap, setA2AEdges]);
|
||||
|
||||
// Initial fetch, then poll every 60 s
|
||||
void fetchAndUpdate();
|
||||
const timer = setInterval(() => void fetchAndUpdate(), A2A_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [showA2AEdges, fetchAndUpdate, setA2AEdges]);
|
||||
// Live-update path. Filters server-side ACTIVITY_LOGGED events down
|
||||
// to delegation initiations from visible workspaces and appends each
|
||||
// into the rolling buffer, re-deriving edges via buildA2AEdges.
|
||||
//
|
||||
// Only `method === "delegate"` rows count — the same filter
|
||||
// buildA2AEdges applies — so delegate_result rows arriving over the
|
||||
// wire don't double-count.
|
||||
useSocketEvent((msg) => {
|
||||
if (!showA2AEdges) return;
|
||||
if (msg.event !== "ACTIVITY_LOGGED") return;
|
||||
|
||||
const p = (msg.payload || {}) as Record<string, unknown>;
|
||||
if (p.activity_type !== "delegation") return;
|
||||
if (p.method !== "delegate") return;
|
||||
|
||||
const wsId = msg.workspace_id;
|
||||
if (!visibleIdsRef.current.includes(wsId)) return;
|
||||
|
||||
// Synthesise an ActivityEntry from the WS payload so buildA2AEdges
|
||||
// (which the bootstrap path also feeds) handles it identically.
|
||||
const entry: ActivityEntry = {
|
||||
id:
|
||||
(p.id as string) ||
|
||||
`ws-push-${msg.timestamp || Date.now()}-${wsId}`,
|
||||
workspace_id: wsId,
|
||||
activity_type: "delegation",
|
||||
source_id: (p.source_id as string | null) ?? null,
|
||||
target_id: (p.target_id as string | null) ?? null,
|
||||
method: "delegate",
|
||||
summary: (p.summary as string | null) ?? null,
|
||||
request_body: null,
|
||||
response_body: null,
|
||||
duration_ms: (p.duration_ms as number | null) ?? null,
|
||||
status: (p.status as string) || "ok",
|
||||
error_detail: null,
|
||||
created_at:
|
||||
(p.created_at as string) ||
|
||||
msg.timestamp ||
|
||||
new Date().toISOString(),
|
||||
};
|
||||
|
||||
bufferRef.current = [...bufferRef.current, entry];
|
||||
recomputeAndPush();
|
||||
});
|
||||
|
||||
// Pure side-effect — renders nothing
|
||||
return null;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { COMM_TYPE_LABELS } from "@/lib/design-tokens";
|
||||
|
||||
interface Communication {
|
||||
@@ -18,32 +19,71 @@ interface Communication {
|
||||
durationMs: number | null;
|
||||
}
|
||||
|
||||
/** Workspace-server `ACTIVITY_LOGGED` payload shape. Pulled out so the
|
||||
* WS handler below has a typed view of the same fields the HTTP
|
||||
* bootstrap consumes — drift between the two paths is a class of bug
|
||||
* AgentCommsPanel hit historically. */
|
||||
interface ActivityLoggedPayload {
|
||||
id?: string;
|
||||
activity_type?: string;
|
||||
source_id?: string | null;
|
||||
target_id?: string | null;
|
||||
workspace_id?: string;
|
||||
summary?: string | null;
|
||||
status?: string;
|
||||
duration_ms?: number | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** Fan-out cap for the bootstrap HTTP fetch on mount / on visibility
|
||||
* re-open. Kept at 3 (carried over from the 2026-05-04 fix) so a
|
||||
* freshly-mounted overlay on a 15-workspace tenant only spends 3
|
||||
* round-trips bootstrapping. Live updates after that arrive via the
|
||||
* WS subscription below — no polling, no fan-out to maintain. */
|
||||
const BOOTSTRAP_FAN_OUT_CAP = 3;
|
||||
|
||||
/** Cap on the rendered list. Bootstrap + every WS push prepends, the
|
||||
* list is sliced to this size after each update. Mirrors the prior
|
||||
* polling-loop behaviour. */
|
||||
const COMMS_RENDER_CAP = 20;
|
||||
|
||||
/**
|
||||
* Overlay showing recent A2A communications between workspaces.
|
||||
* Renders as a floating log panel that auto-updates.
|
||||
*
|
||||
* Update shape (issue #61 Stage 1, replaces the 30s polling loop):
|
||||
* - On mount (when visible): one HTTP bootstrap per online workspace,
|
||||
* capped at BOOTSTRAP_FAN_OUT_CAP. Yields the initial recent-comms
|
||||
* window without waiting for live events.
|
||||
* - Steady state: subscribes to ACTIVITY_LOGGED via useSocketEvent.
|
||||
* Each event with a matching activity_type from a visible online
|
||||
* workspace gets synthesised into a Communication and prepended.
|
||||
* - Visibility re-open: re-bootstraps so the user sees the freshest
|
||||
* window even if WS was idle while collapsed.
|
||||
*
|
||||
* No interval poll. The singleton ReconnectingSocket in `store/socket.ts`
|
||||
* already owns reconnect/backoff/health-check, and `useSocketEvent`
|
||||
* inherits those guarantees. If WS is genuinely unhealthy, the overlay
|
||||
* shows the bootstrap snapshot until the next visibility re-open or
|
||||
* the next WS reconnect (which fires its own rehydrate burst).
|
||||
*/
|
||||
export function CommunicationOverlay() {
|
||||
const [comms, setComms] = useState<Communication[]>([]);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
// nodesRef gives the WS handler current node-name resolution without
|
||||
// re-subscribing on every node-list change. The bus listener is
|
||||
// registered exactly once per mount; subscriber-side filtering reads
|
||||
// the latest value via this ref.
|
||||
const nodesRef = useRef(nodes);
|
||||
nodesRef.current = nodes;
|
||||
|
||||
const fetchComms = useCallback(async () => {
|
||||
const bootstrapComms = useCallback(async () => {
|
||||
try {
|
||||
// Fan-out cap: each polled workspace = 1 round-trip. The platform
|
||||
// rate limits at 600 req/min/IP; combined with heartbeats + other
|
||||
// canvas polling, every workspace polled here costs ~6 req/min
|
||||
// (1 every 30s × 1 per workspace). Capping at 3 keeps this
|
||||
// overlay's footprint at 18 req/min worst case — well under
|
||||
// budget even with 8+ workspaces visible. Caught 2026-05-04 when
|
||||
// a user with 8+ workspaces (Design Director + 6 sub-agents +
|
||||
// 3 standalones) saw sustained 429s in canvas console.
|
||||
const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online");
|
||||
const allComms: Communication[] = [];
|
||||
|
||||
for (const node of onlineNodes.slice(0, 3)) {
|
||||
for (const node of onlineNodes.slice(0, BOOTSTRAP_FAN_OUT_CAP)) {
|
||||
try {
|
||||
const activities = await api.get<Array<{
|
||||
id: string;
|
||||
@@ -59,8 +99,8 @@ export function CommunicationOverlay() {
|
||||
|
||||
for (const a of activities) {
|
||||
if (a.activity_type === "a2a_send" || a.activity_type === "a2a_receive") {
|
||||
const sourceNode = nodes.find((n) => n.id === (a.source_id || a.workspace_id));
|
||||
const targetNode = nodes.find((n) => n.id === (a.target_id || ""));
|
||||
const sourceNode = nodesRef.current.find((n) => n.id === (a.source_id || a.workspace_id));
|
||||
const targetNode = nodesRef.current.find((n) => n.id === (a.target_id || ""));
|
||||
allComms.push({
|
||||
id: a.id,
|
||||
sourceId: a.source_id || a.workspace_id,
|
||||
@@ -76,11 +116,12 @@ export function CommunicationOverlay() {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip workspaces that fail
|
||||
// Per-workspace failures must not blank the panel — the same
|
||||
// robustness the polling version had.
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp, newest first, dedupe
|
||||
// Newest-first with id-dedup, capped at COMMS_RENDER_CAP.
|
||||
const seen = new Set<string>();
|
||||
const sorted = allComms
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
@@ -89,29 +130,78 @@ export function CommunicationOverlay() {
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 20);
|
||||
.slice(0, COMMS_RENDER_CAP);
|
||||
|
||||
setComms(sorted);
|
||||
} catch {
|
||||
// Silently handle API errors
|
||||
// Bootstrap failure is non-blocking — the WS subscription below
|
||||
// will populate the panel as live events arrive.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Bootstrap once on mount + every time the user re-opens after a
|
||||
// collapse. Closed-panel state intentionally drops live updates so
|
||||
// the panel doesn't churn invisible state — the next open reloads.
|
||||
useEffect(() => {
|
||||
// Gate polling on visibility — when the user collapses the overlay
|
||||
// the data isn't being read, so the per-workspace fan-out becomes
|
||||
// pure rate-limit overhead. Pre-fix this overlay polled regardless
|
||||
// of whether the panel was shown, costing ~36 req/min from a
|
||||
// hidden surface.
|
||||
if (!visible) return;
|
||||
fetchComms();
|
||||
// 30s cadence (was 10s). At 3-workspace fan-out that's 6 req/min
|
||||
// worst case from this overlay. Combined with heartbeats (~30/min)
|
||||
// and other canvas polling, leaves ample headroom under the 600/
|
||||
// min/IP server-side rate limit even at 8+ workspace tenants.
|
||||
const interval = setInterval(fetchComms, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchComms, visible]);
|
||||
bootstrapComms();
|
||||
}, [bootstrapComms, visible]);
|
||||
|
||||
// Live-update path. Filters server-side ACTIVITY_LOGGED events down
|
||||
// to the comm-overlay-relevant subset and prepends each into the
|
||||
// rendered list with the same dedup the bootstrap path uses.
|
||||
//
|
||||
// Scope guard: ignore events for workspaces not in the visible online
|
||||
// set, so a user collapsing one workspace doesn't see its comms
|
||||
// continue to scroll in. Same shape the bootstrap path applies.
|
||||
useSocketEvent((msg) => {
|
||||
if (!visible) return;
|
||||
if (msg.event !== "ACTIVITY_LOGGED") return;
|
||||
|
||||
const p = (msg.payload || {}) as ActivityLoggedPayload;
|
||||
const type = p.activity_type;
|
||||
if (type !== "a2a_send" && type !== "a2a_receive" && type !== "task_update") return;
|
||||
|
||||
const wsId = msg.workspace_id;
|
||||
const onlineSet = new Set(
|
||||
nodesRef.current.filter((n) => n.data.status === "online").map((n) => n.id),
|
||||
);
|
||||
if (!onlineSet.has(wsId)) return;
|
||||
|
||||
const sourceId = p.source_id || wsId;
|
||||
const targetId = p.target_id || "";
|
||||
const sourceNode = nodesRef.current.find((n) => n.id === sourceId);
|
||||
const targetNode = nodesRef.current.find((n) => n.id === targetId);
|
||||
|
||||
const incoming: Communication = {
|
||||
id: p.id || `${msg.timestamp || Date.now()}:${sourceId}:${targetId}`,
|
||||
sourceId,
|
||||
targetId,
|
||||
sourceName: sourceNode?.data.name || "Unknown",
|
||||
targetName: targetNode?.data.name || "Unknown",
|
||||
type: type as Communication["type"],
|
||||
summary: p.summary || "",
|
||||
status: p.status || "ok",
|
||||
timestamp: p.created_at || msg.timestamp || new Date().toISOString(),
|
||||
durationMs: p.duration_ms ?? null,
|
||||
};
|
||||
|
||||
setComms((prev) => {
|
||||
// Prepend, dedup by id, re-cap. Functional setState is necessary
|
||||
// because two ACTIVITY_LOGGED events arriving in the same React
|
||||
// batch would otherwise read a stale `comms` from the closure.
|
||||
const seen = new Set<string>();
|
||||
const merged = [incoming, ...prev]
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.filter((c) => {
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, COMMS_RENDER_CAP);
|
||||
return merged;
|
||||
});
|
||||
});
|
||||
|
||||
if (!visible || comms.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import type { MemoryEntry } from "@/components/MemoryInspectorPanel";
|
||||
|
||||
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
|
||||
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
|
||||
|
||||
interface AddProps {
|
||||
open: boolean;
|
||||
mode: "add";
|
||||
workspaceId: string;
|
||||
defaultScope: Scope;
|
||||
defaultNamespace?: string;
|
||||
entry?: undefined;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface EditProps {
|
||||
open: boolean;
|
||||
mode: "edit";
|
||||
workspaceId: string;
|
||||
entry: MemoryEntry;
|
||||
defaultScope?: undefined;
|
||||
defaultNamespace?: undefined;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type Props = AddProps | EditProps;
|
||||
|
||||
export function MemoryEditorDialog(props: Props) {
|
||||
const { open, mode, workspaceId, onClose, onSaved } = props;
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [scope, setScope] = useState<Scope>("LOCAL");
|
||||
const [namespace, setNamespace] = useState("general");
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Reset form whenever the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
if (mode === "edit" && props.entry) {
|
||||
setScope(props.entry.scope);
|
||||
setNamespace(props.entry.namespace || "general");
|
||||
setContent(props.entry.content);
|
||||
} else if (mode === "add") {
|
||||
setScope(props.defaultScope);
|
||||
setNamespace(props.defaultNamespace || "general");
|
||||
setContent("");
|
||||
}
|
||||
// mode/props are stable per-open; intentional shallow deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Move focus into the dialog when it opens (WCAG SC 2.4.3).
|
||||
useEffect(() => {
|
||||
if (!open || !mounted) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLElement>("textarea, input, select")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open, mounted]);
|
||||
|
||||
// Escape closes; Cmd/Ctrl-Enter saves.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const handleSaveRef = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCloseRef.current();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
setError("Content cannot be empty");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === "add") {
|
||||
await api.post(`/workspaces/${workspaceId}/memories`, {
|
||||
content: trimmed,
|
||||
scope,
|
||||
namespace: namespace.trim() || "general",
|
||||
});
|
||||
} else {
|
||||
// PATCH only sends fields that changed. Content always changeable;
|
||||
// namespace only sent if it differs from the original (saves a
|
||||
// no-op write through redactSecrets + re-embed).
|
||||
const original = props.entry;
|
||||
const body: Record<string, string> = {};
|
||||
if (trimmed !== original.content) body.content = trimmed;
|
||||
const ns = namespace.trim() || "general";
|
||||
if (ns !== original.namespace) body.namespace = ns;
|
||||
if (Object.keys(body).length === 0) {
|
||||
// No-op edit — close without an HTTP round-trip.
|
||||
onSaved();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await api.patch(
|
||||
`/workspaces/${workspaceId}/memories/${encodeURIComponent(original.id)}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
const titleId = "memory-editor-title";
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<h3 id={titleId} className="text-sm font-semibold text-ink">
|
||||
{isEdit ? "Edit memory" : "Add memory"}
|
||||
</h3>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-ink-soft block" htmlFor="memory-editor-scope">
|
||||
Scope
|
||||
</label>
|
||||
{isEdit ? (
|
||||
<div
|
||||
id="memory-editor-scope"
|
||||
className="text-[12px] font-mono text-ink-mid bg-surface rounded px-2 py-1.5 border border-line/50"
|
||||
title="Scope is fixed on edit. To move a memory across scopes, delete and re-create it."
|
||||
>
|
||||
{scope}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1" id="memory-editor-scope" role="radiogroup" aria-label="Scope">
|
||||
{SCOPES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={scope === s}
|
||||
onClick={() => setScope(s)}
|
||||
className={[
|
||||
"px-3 py-1 text-[11px] rounded transition-colors",
|
||||
scope === s
|
||||
? "bg-accent-strong text-white"
|
||||
: "bg-surface-card text-ink-mid hover:text-ink",
|
||||
].join(" ")}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="memory-editor-namespace" className="text-[10px] text-ink-soft block">
|
||||
Namespace
|
||||
</label>
|
||||
<input
|
||||
id="memory-editor-namespace"
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
placeholder="general"
|
||||
className="w-full bg-surface border border-line/60 focus:border-accent/60 rounded px-2 py-1.5 text-[12px] text-ink placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="memory-editor-content" className="text-[10px] text-ink-soft block">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="memory-editor-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="What should the agent remember?"
|
||||
className="w-full bg-surface border border-line/60 focus:border-accent/60 rounded px-2 py-1.5 text-[12px] font-mono text-ink placeholder-zinc-600 focus:outline-none transition-colors resize-y min-h-[100px] max-h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-2 py-1.5 bg-red-950/30 border border-red-800/40 rounded text-[11px] text-bad"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3.5 py-1.5 text-[13px] rounded-lg transition-colors bg-accent hover:bg-accent-strong text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving…" : isEdit ? "Save changes" : "Add memory"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { MemoryEditorDialog } from "@/components/MemoryEditorDialog";
|
||||
/**
|
||||
* MemoryInspectorPanel — Memory v2 redesign.
|
||||
*
|
||||
* Reads the canvas Memory tab from the v2 plugin via the
|
||||
* workspace-server proxy at /v2/{namespaces,memories}, replacing the
|
||||
* v1 LOCAL/TEAM/GLOBAL trio that mapped to the deprecated
|
||||
* shared_context model.
|
||||
*
|
||||
* Surface differences from v1:
|
||||
* - Namespace dropdown driven by GET /v2/namespaces (workspace /
|
||||
* team / org / custom — labels rendered server-side).
|
||||
* - Per-row badges for kind (fact|summary|checkpoint), source
|
||||
* (agent|runtime|user), pin (📌), TTL countdown, and propagation
|
||||
* source-workspace if the memory came from a peer.
|
||||
* - No Edit affordance — v2's plugin contract has no PATCH; the
|
||||
* model is forget + recommit. Delete (Forget) stays.
|
||||
*
|
||||
* Shipping note: when the plugin isn't wired (MEMORY_PLUGIN_URL
|
||||
* unset), every endpoint returns 503 with a clear hint. The panel
|
||||
* surfaces that as a banner so operators know to set the env var,
|
||||
* rather than rendering a perpetual empty state that looks like
|
||||
* "no memories yet".
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Memory entry returned by GET /workspaces/:id/memories */
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
content: string;
|
||||
scope: "LOCAL" | "TEAM" | "GLOBAL";
|
||||
namespace: string;
|
||||
created_at: string;
|
||||
/**
|
||||
* Semantic similarity score (0–1). Only present when the API is queried
|
||||
* with ?q=<query> and the pgvector backend has been deployed.
|
||||
* Absent on plain list fetches — renders gracefully without a badge.
|
||||
*/
|
||||
similarity_score?: number;
|
||||
export type NamespaceKind = 'workspace' | 'team' | 'org' | 'custom';
|
||||
|
||||
export interface NamespaceView {
|
||||
name: string;
|
||||
kind: NamespaceKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
|
||||
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
|
||||
export interface NamespacesResponse {
|
||||
readable: NamespaceView[];
|
||||
writable: NamespaceView[];
|
||||
}
|
||||
|
||||
export type MemoryKind = 'fact' | 'summary' | 'checkpoint';
|
||||
export type MemorySource = 'agent' | 'runtime' | 'user';
|
||||
|
||||
export interface MemoryV2 {
|
||||
id: string;
|
||||
namespace: string;
|
||||
content: string;
|
||||
kind: MemoryKind;
|
||||
source: MemorySource;
|
||||
pin: boolean;
|
||||
expires_at?: string | null;
|
||||
created_at: string;
|
||||
/** 0..1 plugin similarity score; only present when ?q= is set. */
|
||||
score?: number | null;
|
||||
// Note: an earlier iteration of this type carried a `source_workspace_id`
|
||||
// field rendered as a "from peer" badge. The propagation contract that
|
||||
// would have populated it ("Reserved for future cross-namespace
|
||||
// propagation semantics" in memory-plugin-v1.yaml) is unimplemented —
|
||||
// nothing in the codebase writes that key. Removed in self-review.
|
||||
// Re-add when propagation gains a concrete shape.
|
||||
}
|
||||
|
||||
interface MemoriesResponse {
|
||||
memories: MemoryV2[];
|
||||
}
|
||||
|
||||
// MemoryEntry kept as a back-compat type alias so any other component
|
||||
// still importing it doesn't break the build. New consumers should
|
||||
// prefer MemoryV2 — the v1 shape (LOCAL/TEAM/GLOBAL scope) is gone.
|
||||
//
|
||||
// `unknown` is used over `any` so TS still flags accidental field
|
||||
// access on the legacy shape.
|
||||
export type MemoryEntry = MemoryV2;
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -32,11 +83,26 @@ interface Props {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitise a memory id for use in an HTML id attribute.
|
||||
*/
|
||||
function sanitizeId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
return id.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a memory-plugin-503 error from the api wrapper's stringified
|
||||
* Error message. Matches on the literal env-var name rather than the
|
||||
* status code, because the api shim renders status codes inside a
|
||||
* larger formatted message and a future status-code reformat would
|
||||
* silently break the detection.
|
||||
*
|
||||
* The substring `MEMORY_PLUGIN_URL` is hard-coded in the handler at
|
||||
* `workspace-server/internal/handlers/memories_v2.go:available()`,
|
||||
* so this is a pinned cross-layer contract — drift is caught by both
|
||||
* the Go test (TestMemoriesV2_PluginUnwired_All503) and the canvas
|
||||
* test (TestMemoryInspectorPanel — plugin unavailable).
|
||||
*/
|
||||
export function isPluginUnavailableError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
return msg.includes('MEMORY_PLUGIN_URL');
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
@@ -47,6 +113,24 @@ function formatRelativeTime(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a TTL countdown like "12h", "3d", or "expired" (when the
|
||||
* stored expires_at is in the past). Non-fatal if expires_at is null
|
||||
* or invalid — falls through to empty string so the badge doesn't
|
||||
* render.
|
||||
*/
|
||||
export function formatTTL(expiresAt: string | null | undefined): string {
|
||||
if (!expiresAt) return '';
|
||||
const ts = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(ts)) return '';
|
||||
const diff = ts - Date.now();
|
||||
if (diff <= 0) return 'expired';
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`;
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
|
||||
return `${Math.floor(diff / 86_400_000)}d`;
|
||||
}
|
||||
|
||||
// ── Skeleton rows ──────────────────────────────────────────────────────────────
|
||||
|
||||
function MemorySkeletonRows() {
|
||||
@@ -71,63 +155,92 @@ function MemorySkeletonRows() {
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALL_NAMESPACES = '__all__';
|
||||
|
||||
export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
const [activeScope, setActiveScope] = useState<Scope>("LOCAL");
|
||||
const [activeNamespace, setActiveNamespace] = useState("");
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<NamespacesResponse | null>(null);
|
||||
const [activeNamespace, setActiveNamespace] = useState<string>(ALL_NAMESPACES);
|
||||
const [entries, setEntries] = useState<MemoryV2[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Search state (debounced) ────────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
// Plugin-disabled banner (503 from server). Stored separately so we
|
||||
// can keep showing the namespace dropdown empty rather than
|
||||
// hiding the whole panel.
|
||||
const [pluginUnavailable, setPluginUnavailable] = useState(false);
|
||||
|
||||
// Search state (debounced)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(
|
||||
() => setDebouncedQuery(searchQuery.trim()),
|
||||
300
|
||||
);
|
||||
const timer = setTimeout(() => setDebouncedQuery(searchQuery.trim()), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// ── Delete state ─────────────────────────────────────────────────────────────
|
||||
// Delete state
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
// ── Editor state (Add + Edit share one modal) ───────────────────────────────
|
||||
type EditorState =
|
||||
| { mode: "add" }
|
||||
| { mode: "edit"; entry: MemoryEntry }
|
||||
| null;
|
||||
const [editorState, setEditorState] = useState<EditorState>(null);
|
||||
// ── Namespace loading ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
const loadNamespaces = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<NamespacesResponse>(
|
||||
`/workspaces/${workspaceId}/v2/namespaces`,
|
||||
);
|
||||
setNamespaces(data);
|
||||
setPluginUnavailable(false);
|
||||
} catch (e) {
|
||||
// Plugin-unavailable (503) indicates MEMORY_PLUGIN_URL isn't set.
|
||||
// Anything else stays as a generic load failure that the
|
||||
// entries-load path will also flag.
|
||||
if (isPluginUnavailableError(e)) {
|
||||
setPluginUnavailable(true);
|
||||
}
|
||||
setNamespaces({ readable: [], writable: [] });
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
// ── Entries loading ────────────────────────────────────────────────────────
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("scope", activeScope);
|
||||
if (debouncedQuery) params.set("q", debouncedQuery);
|
||||
if (activeNamespace) params.set("namespace", activeNamespace);
|
||||
if (activeNamespace !== ALL_NAMESPACES) {
|
||||
params.set('namespace', activeNamespace);
|
||||
}
|
||||
if (debouncedQuery) params.set('q', debouncedQuery);
|
||||
|
||||
const url = `/workspaces/${workspaceId}/memories?${params.toString()}`;
|
||||
const data = await api.get<MemoryEntry[]>(url);
|
||||
const url = `/workspaces/${workspaceId}/v2/memories?${params.toString()}`;
|
||||
const data = await api.get<MemoriesResponse>(url);
|
||||
|
||||
// When a semantic query is active, sort by similarity_score descending.
|
||||
// When a semantic query is active and the plugin returns
|
||||
// scores, sort by score descending so the most-relevant hit
|
||||
// sits at the top. Empty score → push to bottom.
|
||||
const sorted = debouncedQuery
|
||||
? [...data].sort(
|
||||
(a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0)
|
||||
? [...data.memories].sort(
|
||||
(a, b) => (b.score ?? 0) - (a.score ?? 0),
|
||||
)
|
||||
: data;
|
||||
: data.memories;
|
||||
setEntries(sorted);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load memories");
|
||||
if (isPluginUnavailableError(e)) {
|
||||
setPluginUnavailable(true);
|
||||
setError(null); // surfaced via banner, not row error
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load memories');
|
||||
}
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId, activeScope, debouncedQuery, activeNamespace]);
|
||||
}, [workspaceId, activeNamespace, debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNamespaces();
|
||||
}, [loadNamespaces]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
@@ -144,16 +257,35 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`);
|
||||
await api.del(`/workspaces/${workspaceId}/v2/memories/${encodeURIComponent(id)}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Delete failed — reloading...");
|
||||
// Reload first (which clears any stale error), THEN set the
|
||||
// delete-failure message — otherwise loadEntries' own
|
||||
// `setError(null)` wipes our error before the user sees it.
|
||||
// Caught by the rollback test in MemoryInspectorPanel.test.tsx.
|
||||
const msg = e instanceof Error ? e.message : 'Delete failed — reloading…';
|
||||
await loadEntries();
|
||||
setError(msg);
|
||||
}
|
||||
}, [pendingDeleteId, workspaceId, loadEntries]);
|
||||
|
||||
// ── Namespace dropdown options ─────────────────────────────────────────────
|
||||
|
||||
const dropdownOptions = useMemo(() => {
|
||||
const opts: Array<{ value: string; label: string; kind?: NamespaceKind }> = [
|
||||
{ value: ALL_NAMESPACES, label: 'All namespaces' },
|
||||
];
|
||||
if (namespaces) {
|
||||
for (const ns of namespaces.readable) {
|
||||
opts.push({ value: ns.name, label: ns.label, kind: ns.kind });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}, [namespaces]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading && entries.length === 0 && !error) {
|
||||
if (loading && entries.length === 0 && !error && !pluginUnavailable) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-ink-soft">Loading memories…</span>
|
||||
@@ -163,32 +295,43 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Scope tabs */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((scope) => (
|
||||
<button
|
||||
type="button"
|
||||
key={scope}
|
||||
onClick={() => setActiveScope(scope)}
|
||||
aria-pressed={activeScope === scope}
|
||||
className={[
|
||||
"px-3 py-1 text-[11px] rounded transition-colors",
|
||||
activeScope === scope
|
||||
? "bg-accent-strong text-white"
|
||||
: "bg-surface-card text-ink-mid hover:bg-surface-card hover:text-ink",
|
||||
].join(" ")}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
))}
|
||||
{/* Plugin-unavailable banner */}
|
||||
{pluginUnavailable && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded text-xs text-amber-300 shrink-0"
|
||||
data-testid="plugin-unavailable-banner"
|
||||
>
|
||||
Memory plugin not configured. Set <code>MEMORY_PLUGIN_URL</code> on the
|
||||
workspace-server to enable v2 memory.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar + namespace filter */}
|
||||
{/* Namespace dropdown */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-soft shrink-0">
|
||||
Namespace:
|
||||
</label>
|
||||
<select
|
||||
id="namespace-dropdown"
|
||||
value={activeNamespace}
|
||||
onChange={(e) => setActiveNamespace(e.target.value)}
|
||||
aria-label="Filter by namespace"
|
||||
disabled={pluginUnavailable}
|
||||
className="flex-1 bg-surface-sunken border border-line/60 focus:border-accent/60 rounded px-2 py-1 text-[11px] text-ink focus:outline-none transition-colors min-w-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{dropdownOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative flex items-center">
|
||||
{/* Magnifying glass icon */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -206,14 +349,15 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search…"
|
||||
aria-label="Search memories"
|
||||
className="w-full bg-surface-sunken border border-line/60 focus:border-accent/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
disabled={pluginUnavailable}
|
||||
className="w-full bg-surface-sunken border border-line/60 focus:border-accent/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSearchQuery('');
|
||||
setDebouncedQuery('');
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
|
||||
@@ -222,51 +366,26 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="namespace-filter" className="text-[10px] text-ink-soft shrink-0">
|
||||
Namespace:
|
||||
</label>
|
||||
<input
|
||||
id="namespace-filter"
|
||||
type="text"
|
||||
value={activeNamespace}
|
||||
onChange={(e) => setActiveNamespace(e.target.value)}
|
||||
placeholder="all namespaces"
|
||||
aria-label="Filter by namespace"
|
||||
className="flex-1 bg-surface-sunken border border-line/60 focus:border-accent/60 rounded px-2 py-1 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
|
||||
<span className="text-[11px] text-ink-soft">
|
||||
{debouncedQuery
|
||||
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
|
||||
? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
|
||||
: entries.length === 1
|
||||
? "1 memory"
|
||||
: `${entries.length} memories`}
|
||||
? '1 memory'
|
||||
: `${entries.length} memories`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorState({ mode: "add" })}
|
||||
className="px-2 py-1 text-[11px] bg-accent hover:bg-accent-strong text-white rounded transition-colors"
|
||||
aria-label="Add memory"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
disabled={pluginUnavailable}
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
@@ -285,47 +404,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{loading ? (
|
||||
<MemorySkeletonRows />
|
||||
) : entries.length === 0 ? (
|
||||
debouncedQuery ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-ink-mid">
|
||||
No memories match your search
|
||||
</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
Try a different query or{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
}}
|
||||
className="text-accent hover:text-accent underline transition-colors"
|
||||
>
|
||||
clear the search
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No {activeScope} memories</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
{activeScope === "LOCAL"
|
||||
? "This workspace has not written any local memories yet."
|
||||
: activeScope === "TEAM"
|
||||
? "No team memories shared with this workspace yet."
|
||||
: "No global memories exist yet."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
<EmptyState query={debouncedQuery} pluginUnavailable={pluginUnavailable} />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((entry) => (
|
||||
<MemoryEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => setEditorState({ mode: "edit", entry })}
|
||||
onDelete={() => setPendingDeleteId(entry.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -336,36 +421,64 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteId !== null}
|
||||
title="Delete memory"
|
||||
message={`Delete this ${activeScope} memory? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
title="Forget memory"
|
||||
message="Forget this memory? This cannot be undone."
|
||||
confirmLabel="Forget"
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Add / Edit dialog */}
|
||||
{editorState?.mode === "add" && (
|
||||
<MemoryEditorDialog
|
||||
open={true}
|
||||
mode="add"
|
||||
workspaceId={workspaceId}
|
||||
defaultScope={activeScope}
|
||||
defaultNamespace={activeNamespace || "general"}
|
||||
onClose={() => setEditorState(null)}
|
||||
onSaved={loadEntries}
|
||||
/>
|
||||
)}
|
||||
{editorState?.mode === "edit" && (
|
||||
<MemoryEditorDialog
|
||||
open={true}
|
||||
mode="edit"
|
||||
workspaceId={workspaceId}
|
||||
entry={editorState.entry}
|
||||
onClose={() => setEditorState(null)}
|
||||
onSaved={loadEntries}
|
||||
/>
|
||||
)}
|
||||
// ── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({
|
||||
query,
|
||||
pluginUnavailable,
|
||||
}: {
|
||||
query: string;
|
||||
pluginUnavailable: boolean;
|
||||
}) {
|
||||
if (pluginUnavailable) {
|
||||
// The banner already explains the problem; the empty rows just
|
||||
// mirror it so the operator sees both signals.
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
|
||||
See banner above for the operator-side fix.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No memories match your search</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
Try a different query or clear the search.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No memories yet</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
|
||||
Agents commit memories via MCP tools (commit_memory, commit_summary). They
|
||||
appear here once written.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -373,17 +486,32 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
// ── MemoryEntryRow sub-component ──────────────────────────────────────────────
|
||||
|
||||
interface MemoryEntryRowProps {
|
||||
entry: MemoryEntry;
|
||||
onEdit: () => void;
|
||||
entry: MemoryV2;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function MemoryEntryRow({ entry, onEdit, onDelete }: MemoryEntryRowProps) {
|
||||
const KIND_BADGE_CLASS: Record<MemoryKind, string> = {
|
||||
fact: 'bg-surface-card text-ink-mid',
|
||||
summary: 'bg-blue-950 text-accent',
|
||||
checkpoint: 'bg-violet-950 text-violet-400',
|
||||
};
|
||||
|
||||
const SOURCE_BADGE_CLASS: Record<MemorySource, string> = {
|
||||
agent: 'bg-surface-card text-ink-mid',
|
||||
runtime: 'bg-amber-950 text-amber-300',
|
||||
user: 'bg-emerald-950 text-emerald-400',
|
||||
};
|
||||
|
||||
function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
|
||||
const ttl = formatTTL(entry.expires_at);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line/60 bg-surface-sunken/50 overflow-hidden">
|
||||
<div
|
||||
className="rounded-lg border border-line/60 bg-surface-sunken/50 overflow-hidden"
|
||||
data-testid={`memory-row-${entry.id}`}
|
||||
>
|
||||
{/* Header row */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -392,52 +520,89 @@ function MemoryEntryRow({ entry, onEdit, onDelete }: MemoryEntryRowProps) {
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
>
|
||||
{/* Scope badge */}
|
||||
{/* Kind badge */}
|
||||
<span
|
||||
className={[
|
||||
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
|
||||
entry.scope === "LOCAL"
|
||||
? "bg-surface-card text-ink-mid"
|
||||
: entry.scope === "TEAM"
|
||||
? "bg-blue-950 text-accent"
|
||||
: "bg-violet-950 text-violet-400",
|
||||
].join(" ")}
|
||||
title={`Scope: ${entry.scope}`}
|
||||
'text-[9px] shrink-0 font-mono px-1 py-0.5 rounded',
|
||||
KIND_BADGE_CLASS[entry.kind] ?? 'bg-surface-card text-ink-mid',
|
||||
].join(' ')}
|
||||
title={`Kind: ${entry.kind}`}
|
||||
data-testid="kind-badge"
|
||||
>
|
||||
{entry.scope[0]}
|
||||
{entry.kind[0].toUpperCase()}
|
||||
</span>
|
||||
|
||||
{/* Source badge */}
|
||||
<span
|
||||
className={[
|
||||
'text-[9px] shrink-0 font-mono px-1 py-0.5 rounded',
|
||||
SOURCE_BADGE_CLASS[entry.source] ?? 'bg-surface-card text-ink-mid',
|
||||
].join(' ')}
|
||||
title={`Source: ${entry.source}`}
|
||||
data-testid="source-badge"
|
||||
>
|
||||
{entry.source}
|
||||
</span>
|
||||
|
||||
{/* Pin indicator */}
|
||||
{entry.pin && (
|
||||
<span
|
||||
className="text-[9px] shrink-0"
|
||||
title="Pinned"
|
||||
data-testid="pin-badge"
|
||||
aria-label="Pinned"
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Namespace tag */}
|
||||
<span className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[80px]" title={entry.namespace}>
|
||||
<span
|
||||
className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[100px]"
|
||||
title={entry.namespace}
|
||||
>
|
||||
{entry.namespace}
|
||||
</span>
|
||||
|
||||
{/* Content preview */}
|
||||
<span className="flex-1 min-w-0 text-[10px] font-mono text-ink-mid truncate text-left">
|
||||
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
|
||||
{entry.content.length > 60 ? entry.content.slice(0, 60) + '…' : entry.content}
|
||||
</span>
|
||||
|
||||
{/* Similarity badge */}
|
||||
{entry.similarity_score != null && (
|
||||
{/* Score badge (semantic search only) */}
|
||||
{entry.score != null && (
|
||||
<span
|
||||
className={[
|
||||
"text-[9px] shrink-0 font-mono tabular-nums",
|
||||
entry.similarity_score >= 0.8
|
||||
? "text-accent"
|
||||
: "text-ink-mid",
|
||||
].join(" ")}
|
||||
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
|
||||
data-testid="similarity-badge"
|
||||
'text-[9px] shrink-0 font-mono tabular-nums',
|
||||
entry.score >= 0.8 ? 'text-accent' : 'text-ink-mid',
|
||||
].join(' ')}
|
||||
title={`Similarity: ${(entry.score * 100).toFixed(1)}%`}
|
||||
data-testid="score-badge"
|
||||
>
|
||||
{Math.round(entry.similarity_score * 100)}%
|
||||
{Math.round(entry.score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* TTL countdown */}
|
||||
{ttl && (
|
||||
<span
|
||||
className={[
|
||||
'text-[9px] shrink-0 font-mono',
|
||||
ttl === 'expired' ? 'text-bad' : 'text-amber-400',
|
||||
].join(' ')}
|
||||
title={`Expires: ${entry.expires_at}`}
|
||||
data-testid="ttl-badge"
|
||||
>
|
||||
⌛{ttl}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
<span className="text-[9px] text-ink-soft shrink-0">
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -455,31 +620,19 @@ function MemoryEntryRow({ entry, onEdit, onDelete }: MemoryEntryRowProps) {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-ink-soft">
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
aria-label="Edit memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-surface-card hover:bg-surface-elevated border border-line/40 rounded text-ink-mid hover:text-ink transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Delete memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Forget memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
|
||||
>
|
||||
Forget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PurchaseSuccessModal — demo-only post-purchase confirmation.
|
||||
*
|
||||
* Mounted on the canvas root (`app/page.tsx`). On first paint it inspects
|
||||
* `?purchase_success=1[&item=<name>]` on the current URL. If present, it
|
||||
* renders a centred modal styled after `ConfirmDialog`, schedules a 5s
|
||||
* auto-dismiss, and rewrites the URL via `history.replaceState` to drop
|
||||
* the params so a refresh after dismiss does NOT re-show the modal.
|
||||
*
|
||||
* Mock for the funding demo — there is no real billing surface behind
|
||||
* this. The marketplace "Purchase" button on the landing page redirects
|
||||
* here with the params; this modal is the only thing the user sees of
|
||||
* the "transaction".
|
||||
*
|
||||
* Styling matches the warm-paper @theme tokens (surface-sunken / line /
|
||||
* ink / good) so it tracks light + dark without per-mode overrides.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const AUTO_DISMISS_MS = 5000;
|
||||
|
||||
function readPurchaseParams(): { open: boolean; item: string | null } {
|
||||
if (typeof window === "undefined") return { open: false, item: null };
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const flag = sp.get("purchase_success");
|
||||
if (flag !== "1" && flag !== "true") return { open: false, item: null };
|
||||
return { open: true, item: sp.get("item") };
|
||||
}
|
||||
|
||||
function stripPurchaseParams() {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("purchase_success");
|
||||
url.searchParams.delete("item");
|
||||
// replaceState (not pushState) so back-button doesn't return to the
|
||||
// pre-strip URL and re-trigger the modal.
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export function PurchaseSuccessModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [item, setItem] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Read the URL params once on mount. We don't subscribe to navigation —
|
||||
// this modal is a one-shot for the demo redirect, not a persistent
|
||||
// listener.
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const { open: shouldOpen, item: itemName } = readPurchaseParams();
|
||||
if (shouldOpen) {
|
||||
setOpen(true);
|
||||
setItem(itemName);
|
||||
// Clean the URL immediately so a refresh after the modal is closed
|
||||
// (or even while it's still open) does NOT re-trigger it.
|
||||
stripPurchaseParams();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-dismiss timer + Escape handler.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(() => setOpen(false), AUTO_DISMISS_MS);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
// Focus the close button so keyboard users land on it after redirect.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
const itemLabel = item ? decodeURIComponent(item) : "Your new agent";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
data-testid="purchase-success-modal"
|
||||
>
|
||||
{/* Backdrop — click closes, matches ConfirmDialog backdrop. */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="purchase-success-title"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Success glyph — uses --color-good so it tracks the theme.
|
||||
Inline SVG over an emoji so it stays readable + on-brand
|
||||
in both light and dark. */}
|
||||
<div
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-good) 15%, transparent)",
|
||||
color: "var(--color-good)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 12.5L10.5 15.5L16.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
id="purchase-success-title"
|
||||
className="text-base font-semibold text-ink"
|
||||
>
|
||||
Purchase successful
|
||||
</h3>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-ink-mid">
|
||||
<span className="font-medium text-ink">{itemLabel}</span> has
|
||||
been added to your workspace. Provisioning starts in the
|
||||
background — you can keep working while it spins up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
|
||||
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-soft">
|
||||
auto-dismiss · {AUTO_DISMISS_MS / 1000}s
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="px-3.5 py-1.5 text-[13px] rounded-lg bg-accent hover:bg-accent-strong text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -287,7 +287,7 @@ export function SidePanel() {
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "memory" && <MemoryInspectorPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@@ -41,6 +41,10 @@ vi.mock("@/store/canvas", () => ({
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
emitSocketEvent,
|
||||
_resetSocketEventListenersForTests,
|
||||
} from "@/store/socket-events";
|
||||
import {
|
||||
buildA2AEdges,
|
||||
formatA2ARelativeTime,
|
||||
@@ -342,6 +346,151 @@ describe("A2ATopologyOverlay component", () => {
|
||||
expect(mockGet.mock.calls.length).toBe(callsAfterMount);
|
||||
});
|
||||
|
||||
// ── #61 Stage 2: ACTIVITY_LOGGED subscription tests ────────────────────────
|
||||
//
|
||||
// Pin the post-#61 behaviour: WS push for delegation contributes to
|
||||
// the overlay's edge buffer with NO additional HTTP fetch. Same shape
|
||||
// as Stage 1 (CommunicationOverlay).
|
||||
|
||||
describe("#61 stage 2 — ACTIVITY_LOGGED subscription", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
afterEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
function emitDelegation(overrides: {
|
||||
workspaceId?: string;
|
||||
sourceId?: string;
|
||||
targetId?: string;
|
||||
method?: string;
|
||||
activityType?: string;
|
||||
} = {}) {
|
||||
// Use Date.now() (real time, fake-timer-frozen) rather than the
|
||||
// hardcoded NOW constant — buildA2AEdges prunes by Date.now() -
|
||||
// A2A_WINDOW_MS, so a row dated against the wrong epoch silently
|
||||
// falls outside the window and the test fails for a confusing
|
||||
// reason ("edges array empty" vs "filter dropped my row").
|
||||
const realNow = Date.now();
|
||||
emitSocketEvent({
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: overrides.workspaceId ?? "ws-a",
|
||||
timestamp: new Date(realNow).toISOString(),
|
||||
payload: {
|
||||
id: `act-${Math.random().toString(36).slice(2)}`,
|
||||
activity_type: overrides.activityType ?? "delegation",
|
||||
method: overrides.method ?? "delegate",
|
||||
source_id: overrides.sourceId ?? "ws-a",
|
||||
target_id: overrides.targetId ?? "ws-b",
|
||||
status: "ok",
|
||||
created_at: new Date(realNow - 30_000).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("does NOT poll on a 60s interval after bootstrap (post-#61)", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
const callsAfterBootstrap = mockGet.mock.calls.length;
|
||||
expect(callsAfterBootstrap).toBe(2); // ws-a + ws-b
|
||||
|
||||
// Pre-#61: a 60s clock tick would fire a fresh fan-out (2 more
|
||||
// calls). Post-#61: no interval, no extra calls.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(120_000);
|
||||
});
|
||||
expect(mockGet.mock.calls.length).toBe(callsAfterBootstrap);
|
||||
});
|
||||
|
||||
it("WS push for a delegation event from a visible workspace updates edges with NO HTTP call", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
mockGet.mockClear();
|
||||
mockStoreState.setA2AEdges.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitDelegation({ sourceId: "ws-a", targetId: "ws-b" });
|
||||
});
|
||||
|
||||
// Edges-set called with at least one a2a edge for the new push.
|
||||
const calls = mockStoreState.setA2AEdges.mock.calls;
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
const lastCall = calls[calls.length - 1][0] as Array<{ id: string }>;
|
||||
expect(lastCall.some((e) => e.id === "a2a-ws-a-ws-b")).toBe(true);
|
||||
|
||||
// Critical: no HTTP fetch fired during the WS path.
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push for a non-delegation activity_type is ignored", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
mockStoreState.setA2AEdges.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitDelegation({ activityType: "a2a_send" });
|
||||
});
|
||||
|
||||
// setA2AEdges must not be called by the WS handler — the only
|
||||
// setA2AEdges calls in this test came from the initial bootstrap.
|
||||
expect(mockStoreState.setA2AEdges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push for a delegate_result row is ignored (mirrors buildA2AEdges filter)", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
mockStoreState.setA2AEdges.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitDelegation({ method: "delegate_result" });
|
||||
});
|
||||
|
||||
// delegate_result rows do not contribute to the edge count — they
|
||||
// are completion signals, not initiations.
|
||||
expect(mockStoreState.setA2AEdges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push from a hidden workspace is ignored", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<A2ATopologyOverlay />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
mockStoreState.setA2AEdges.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitDelegation({ workspaceId: "ws-hidden" });
|
||||
});
|
||||
|
||||
expect(mockStoreState.setA2AEdges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push while showA2AEdges is false is ignored", async () => {
|
||||
mockStoreState.showA2AEdges = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<A2ATopologyOverlay />);
|
||||
// The mount path with showA2AEdges=false calls setA2AEdges([])
|
||||
// once — clear that to isolate the WS path.
|
||||
mockStoreState.setA2AEdges.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitDelegation();
|
||||
});
|
||||
|
||||
expect(mockStoreState.setA2AEdges).not.toHaveBeenCalled();
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-fetches when the visible ID set actually changes", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
|
||||
@@ -36,6 +36,10 @@ vi.mock("@/hooks/useWorkspaceName", () => ({
|
||||
useWorkspaceName: () => () => "Test WS",
|
||||
}));
|
||||
|
||||
import {
|
||||
emitSocketEvent,
|
||||
_resetSocketEventListenersForTests,
|
||||
} from "@/store/socket-events";
|
||||
import { ActivityTab } from "../tabs/ActivityTab";
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
@@ -358,6 +362,191 @@ describe("ActivityTab — refresh button", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 6.5: ACTIVITY_LOGGED subscription (#61 stage 3) ─────────────────────
|
||||
//
|
||||
// Pin the post-#61 behaviour: WS push extends the rendered list with NO
|
||||
// additional HTTP fetch. The 5s polling loop is gone; live updates
|
||||
// arrive over the WebSocket bus.
|
||||
|
||||
describe("ActivityTab — #61 stage 3: ACTIVITY_LOGGED subscription", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockResolvedValue([]);
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
function emitActivity(overrides: {
|
||||
workspaceId?: string;
|
||||
activityType?: string;
|
||||
summary?: string;
|
||||
id?: string;
|
||||
} = {}) {
|
||||
const realNow = Date.now();
|
||||
emitSocketEvent({
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: overrides.workspaceId ?? "ws-1",
|
||||
timestamp: new Date(realNow).toISOString(),
|
||||
payload: {
|
||||
id: overrides.id ?? `act-${Math.random().toString(36).slice(2)}`,
|
||||
activity_type: overrides.activityType ?? "agent_log",
|
||||
source_id: null,
|
||||
target_id: null,
|
||||
method: null,
|
||||
summary: overrides.summary ?? "live-pushed",
|
||||
status: "ok",
|
||||
created_at: new Date(realNow - 5_000).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("WS push for matching workspace prepends to the list with NO HTTP call", async () => {
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/0 activities|no activity/i)).toBeTruthy();
|
||||
});
|
||||
mockGet.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitActivity({ summary: "live-row-from-bus" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/live-row-from-bus/)).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push for a different workspace is ignored", async () => {
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText(/no activity/i));
|
||||
|
||||
await act(async () => {
|
||||
emitActivity({
|
||||
workspaceId: "ws-other",
|
||||
summary: "should-not-render-other-ws",
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/should-not-render-other-ws/)).toBeNull();
|
||||
});
|
||||
|
||||
it("WS push respects the active filter — non-matching activity_type is ignored", async () => {
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText(/no activity/i));
|
||||
|
||||
// Apply "Tasks" filter.
|
||||
clickButton(/tasks/i);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /tasks/i }).getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
// Push an a2a_send (does NOT match task_update filter).
|
||||
await act(async () => {
|
||||
emitActivity({
|
||||
activityType: "a2a_send",
|
||||
summary: "should-not-render-filter-mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText(/should-not-render-filter-mismatch/),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("WS push respects the active filter — matching activity_type is rendered", async () => {
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText(/no activity/i));
|
||||
|
||||
clickButton(/tasks/i);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /tasks/i }).getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emitActivity({
|
||||
activityType: "task_update",
|
||||
summary: "task-filter-match",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/task-filter-match/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("WS push while autoRefresh is paused is ignored", async () => {
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText(/no activity/i));
|
||||
|
||||
// Toggle Live → Paused.
|
||||
clickButton(/live/i);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Paused/)).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emitActivity({ summary: "should-not-render-paused" });
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/should-not-render-paused/)).toBeNull();
|
||||
});
|
||||
|
||||
it("WS push for a row already in the list is deduped (no double-render)", async () => {
|
||||
// Bootstrap with one row — same id as the WS push to trigger dedup.
|
||||
mockGet.mockResolvedValueOnce([
|
||||
makeEntry({ id: "shared-id", summary: "bootstrap-summary" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/bootstrap-summary/)).toBeTruthy();
|
||||
});
|
||||
mockGet.mockClear();
|
||||
|
||||
// Push a row with the SAME id but a different summary — must not
|
||||
// render the new summary; original row stays.
|
||||
await act(async () => {
|
||||
emitActivity({
|
||||
id: "shared-id",
|
||||
summary: "should-not-replace-existing",
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/should-not-replace-existing/)).toBeNull();
|
||||
// Also verify count didn't grow.
|
||||
expect(screen.getByText(/1 activities/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT poll on a 5s interval after mount (post-#61)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
// Drain the mount-time bootstrap promise.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const callsAfterBootstrap = mockGet.mock.calls.length;
|
||||
expect(callsAfterBootstrap).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Pre-#61: a 30s clock advance fires 6 more polls. Post-#61: 0.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(mockGet.mock.calls.length).toBe(callsAfterBootstrap);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 7: Activity count ───────────────────────────────────────────────────
|
||||
|
||||
describe("ActivityTab — activity count", () => {
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* CommunicationOverlay tests — pin the rate-limit fix shipped 2026-05-04.
|
||||
* CommunicationOverlay tests — pin both the 2026-05-04 fan-out cap fix
|
||||
* AND the 2026-05-07 polling → ACTIVITY_LOGGED-subscriber refactor
|
||||
* (issue #61 stage 1).
|
||||
*
|
||||
* The overlay polls /workspaces/:id/activity?limit=5 for each online
|
||||
* workspace. Pre-fix it (a) polled regardless of visibility and (b)
|
||||
* fanned out to 6 workspaces every 10s. With 8+ workspaces a user
|
||||
* triggered sustained 429s (server-side rate limit is 600 req/min/IP).
|
||||
* The overlay used to poll /workspaces/:id/activity?limit=5 on a 30s
|
||||
* interval per online workspace (capped at 3). Post-#61: it bootstraps
|
||||
* once on mount via the same HTTP path (cap of 3 retained), then
|
||||
* subscribes to ACTIVITY_LOGGED via the global socket bus for live
|
||||
* updates. No interval poll.
|
||||
*
|
||||
* These tests pin:
|
||||
* 1. Fan-out cap of 3 — even with 6 online nodes, only 3 fetches
|
||||
* 2. Visibility gate — when collapsed, no polling
|
||||
* 1. Bootstrap fan-out cap of 3 — even with 6 online nodes, only 3
|
||||
* HTTP fetches on mount.
|
||||
* 2. Visibility gate — when collapsed, no HTTP fetches; re-open
|
||||
* re-bootstraps.
|
||||
* 3. NO interval polling — advancing the clock past 30s does not fire
|
||||
* additional HTTP calls.
|
||||
* 4. WS push extends the rendered list without firing any HTTP call.
|
||||
* 5. WS push for an offline workspace is ignored.
|
||||
* 6. WS push for a non-comm activity_type is ignored.
|
||||
*
|
||||
* If a future refactor pushes either dial back up, CI fails before
|
||||
* the regression hits a paying tenant.
|
||||
* If a future refactor regresses any of these, CI fails before the
|
||||
* regression hits a paying tenant.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent } from "@testing-library/react";
|
||||
@@ -23,7 +33,7 @@ vi.mock("@/lib/api", () => ({
|
||||
api: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
// Six online nodes — enough to verify the cap of 3.
|
||||
// Six online nodes — enough to verify the bootstrap cap of 3.
|
||||
const mockStoreState = {
|
||||
selectedNodeId: null as string | null,
|
||||
nodes: [
|
||||
@@ -56,6 +66,10 @@ vi.mock("@/lib/design-tokens", () => ({
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
emitSocketEvent,
|
||||
_resetSocketEventListenersForTests,
|
||||
} from "@/store/socket-events";
|
||||
import { CommunicationOverlay } from "../CommunicationOverlay";
|
||||
|
||||
const mockGet = vi.mocked(api.get);
|
||||
@@ -66,30 +80,34 @@ beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGet.mockReset();
|
||||
mockGet.mockResolvedValue([]);
|
||||
// Drop any subscribers the previous test left on the singleton bus —
|
||||
// each render adds one via useSocketEvent.
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CommunicationOverlay — fan-out cap", () => {
|
||||
it("polls at most 3 of 6 online workspaces (rate-limit floor)", async () => {
|
||||
describe("CommunicationOverlay — bootstrap fan-out cap", () => {
|
||||
it("bootstraps at most 3 of 6 online workspaces (rate-limit floor preserved post-#61)", async () => {
|
||||
await act(async () => {
|
||||
render(<CommunicationOverlay />);
|
||||
});
|
||||
// Mount fires the first poll synchronously (no interval tick yet).
|
||||
// Pre-fix: 6 calls. Post-fix: 3.
|
||||
// Mount fires the bootstrap synchronously — pre-#61 this was the
|
||||
// first poll cycle; post-#61 it's the only HTTP fetch (live updates
|
||||
// arrive via WS push). 6 nodes → 3 fetches.
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
// Verify the calls are for the FIRST 3 online nodes (slice order).
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/activity?limit=5");
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-2/activity?limit=5");
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-3/activity?limit=5");
|
||||
});
|
||||
|
||||
it("never polls offline workspaces", async () => {
|
||||
it("never bootstraps offline workspaces", async () => {
|
||||
await act(async () => {
|
||||
render(<CommunicationOverlay />);
|
||||
});
|
||||
@@ -99,40 +117,39 @@ describe("CommunicationOverlay — fan-out cap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommunicationOverlay — cadence", () => {
|
||||
it("uses 30s interval cadence (was 10s pre-fix)", async () => {
|
||||
describe("CommunicationOverlay — no interval polling (post-#61)", () => {
|
||||
// The pre-#61 implementation re-fetched every 30s per workspace.
|
||||
// Post-#61 the only HTTP path is the bootstrap on mount + on
|
||||
// visibility-toggle. This test pins the absence of any interval
|
||||
// poll: a 60s clock advance must not produce a second round of
|
||||
// fetches.
|
||||
it("does NOT poll on a 30s interval after bootstrap", async () => {
|
||||
await act(async () => {
|
||||
render(<CommunicationOverlay />);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(3); // initial mount poll
|
||||
expect(mockGet).toHaveBeenCalledTimes(3); // initial bootstrap
|
||||
mockGet.mockClear();
|
||||
|
||||
// Advance 10s — pre-fix this would fire another poll. Post-fix: silent.
|
||||
// Advance 60s — well past any plausible cadence the prior version
|
||||
// could have used.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Advance to 30s — interval fires.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(6); // +3 from second tick
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommunicationOverlay — visibility gate", () => {
|
||||
// The visibility gate is the dial that drops collapsed-panel polling
|
||||
// to ZERO. The cadence test above can't catch its removal — if a
|
||||
// refactor dropped `if (!visible) return`, the cadence test would
|
||||
// still pass because the effect would still fire every 30s.
|
||||
// The visibility gate now does two things post-#61:
|
||||
// - while closed, the WS handler short-circuits (no setComms churn)
|
||||
// - re-opening triggers a fresh bootstrap so the list reflects
|
||||
// anything that happened while the panel was collapsed
|
||||
//
|
||||
// Direct probe: render with comms-returning mock so the panel
|
||||
// actually renders (close button only exists in the expanded panel,
|
||||
// not the collapsed button-state). Click close, advance the clock,
|
||||
// assert no further fetches.
|
||||
it("stops polling after the user collapses the panel", async () => {
|
||||
// Mock returns one a2a_send so comms.length > 0 → panel renders →
|
||||
// close button accessible.
|
||||
it("stops fetching while collapsed and re-bootstraps on re-open", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{
|
||||
id: "act-1",
|
||||
@@ -150,29 +167,202 @@ describe("CommunicationOverlay — visibility gate", () => {
|
||||
const { getByLabelText } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
// Drain pending microtasks (resolves the await in fetchComms) so
|
||||
// setComms lands and the panel renders. Don't advance time — that
|
||||
// would fire the next interval tick and pollute the assertion.
|
||||
// Drain pending microtasks (resolves the await in bootstrap) so
|
||||
// setComms lands and the panel renders. Don't advance time — it's
|
||||
// not load-bearing for the gate test, but matches the pattern used
|
||||
// pre-#61 for stability.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Initial mount polled 3 workspaces.
|
||||
expect(mockGet).toHaveBeenCalledTimes(3);
|
||||
expect(mockGet).toHaveBeenCalledTimes(3); // initial bootstrap
|
||||
mockGet.mockClear();
|
||||
|
||||
// Click the close button. Synchronous getByLabelText avoids
|
||||
// findBy's internal setTimeout (deadlocks under useFakeTimers).
|
||||
// Click close. While closed, no fetches and no WS-driven updates.
|
||||
const closeBtn = getByLabelText("Close communications panel");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
|
||||
// Re-open via the collapsed button. Must trigger a fresh bootstrap.
|
||||
const openBtn = getByLabelText("Show communications panel");
|
||||
await act(async () => {
|
||||
fireEvent.click(openBtn);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(3); // re-bootstrap on re-open
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommunicationOverlay — WS subscription (#61 stage 1 core)", () => {
|
||||
// The load-bearing post-#61 behaviour. Every test in this block must
|
||||
// verify (a) the WS push DID update the rendered comms list, and
|
||||
// (b) NO additional HTTP call was fired — the whole point of the
|
||||
// refactor is to remove the polling-driven HTTP traffic.
|
||||
function emitActivityLogged(overrides: Partial<{
|
||||
workspaceId: string;
|
||||
payload: Record<string, unknown>;
|
||||
}> = {}) {
|
||||
emitSocketEvent({
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: overrides.workspaceId ?? "ws-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
id: `act-${Math.random().toString(36).slice(2)}`,
|
||||
activity_type: "a2a_send",
|
||||
source_id: "ws-1",
|
||||
target_id: "ws-2",
|
||||
summary: "live push",
|
||||
status: "ok",
|
||||
duration_ms: 42,
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides.payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("WS push for a comm activity_type extends the rendered list with NO additional HTTP call", async () => {
|
||||
const { container } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledTimes(3); // bootstrap
|
||||
mockGet.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitActivityLogged({ payload: { summary: "hello" } });
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Two pins:
|
||||
// 1. comms list reflects the live push (look for the summary text)
|
||||
// 2. zero HTTP fetches fired during the WS path
|
||||
expect(container.textContent).toContain("hello");
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push for an offline workspace is ignored", async () => {
|
||||
const { container } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
mockGet.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitActivityLogged({
|
||||
workspaceId: "ws-offline",
|
||||
payload: { source_id: "ws-offline", summary: "should-not-render" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("should-not-render");
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push for a non-comm activity_type is ignored (e.g. delegation)", async () => {
|
||||
const { container } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
mockGet.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitActivityLogged({
|
||||
payload: {
|
||||
activity_type: "delegation",
|
||||
summary: "should-not-render-delegation",
|
||||
},
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("should-not-render-delegation");
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("WS push while the panel is collapsed is ignored (no churn on hidden state)", async () => {
|
||||
// Bootstrap with one comm so the panel renders → close button
|
||||
// accessible. Then collapse, emit a WS push, re-open: the rendered
|
||||
// list must come from the re-bootstrap, NOT from the WS-push that
|
||||
// arrived during the closed state. Also: nothing visible while
|
||||
// closed (the collapsed button shows only the count, not summaries).
|
||||
mockGet.mockResolvedValue([
|
||||
{
|
||||
id: "act-bootstrap",
|
||||
workspace_id: "ws-1",
|
||||
activity_type: "a2a_send",
|
||||
source_id: "ws-1",
|
||||
target_id: "ws-2",
|
||||
summary: "bootstrap-summary",
|
||||
status: "ok",
|
||||
duration_ms: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const { getByLabelText, container } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Collapse.
|
||||
const closeBtn = getByLabelText("Close communications panel");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn);
|
||||
});
|
||||
|
||||
// Advance well past the 30s cadence — gate should suppress the tick.
|
||||
// Bootstrap mock returns nothing on the re-open path so we can
|
||||
// distinguish "WS push leaked through the gate" from "re-bootstrap
|
||||
// refilled the list."
|
||||
mockGet.mockReset();
|
||||
mockGet.mockResolvedValue([]);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
emitActivityLogged({
|
||||
payload: { summary: "leaked-while-closed" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Closed state: rendered DOM must not show any push-derived text.
|
||||
expect(container.textContent).not.toContain("leaked-while-closed");
|
||||
});
|
||||
|
||||
it("non-ACTIVITY_LOGGED events are ignored (e.g. WORKSPACE_OFFLINE)", async () => {
|
||||
const { container } = await act(async () => {
|
||||
return render(<CommunicationOverlay />);
|
||||
});
|
||||
mockGet.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
emitSocketEvent({
|
||||
event: "WORKSPACE_OFFLINE",
|
||||
workspace_id: "ws-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: { summary: "should-not-render-event" },
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("should-not-render-event");
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MemoryEditorDialog tests — covers Add (POST /memories) and Edit
|
||||
* (PATCH /memories/:id) flows. Pins:
|
||||
* - Add posts {content, scope, namespace} with the trimmed defaults
|
||||
* - Edit only sends fields that changed (no-op edit short-circuits, no PATCH fires)
|
||||
* - Empty content blocks save
|
||||
* - Save error surfaces in the dialog and keeps the modal open
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { MemoryEditorDialog } from "../MemoryEditorDialog";
|
||||
import type { MemoryEntry } from "../MemoryInspectorPanel";
|
||||
|
||||
const mockPost = vi.mocked(api.post);
|
||||
const mockPatch = vi.mocked(api.patch);
|
||||
|
||||
const SAMPLE: MemoryEntry = {
|
||||
id: "mem-x",
|
||||
workspace_id: "ws-1",
|
||||
content: "original content",
|
||||
scope: "TEAM",
|
||||
namespace: "procedures",
|
||||
created_at: "2026-04-17T12:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPost.mockResolvedValue({} as never);
|
||||
mockPatch.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Add mode", () => {
|
||||
it("POSTs scope+namespace+trimmed-content and calls onSaved+onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="add"
|
||||
workspaceId="ws-1"
|
||||
defaultScope="GLOBAL"
|
||||
defaultNamespace="facts"
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText(/Content/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: " new fact " } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add memory$/i }));
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1));
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-1/memories", {
|
||||
content: "new fact",
|
||||
scope: "GLOBAL",
|
||||
namespace: "facts",
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks save when content is empty (whitespace-only)", () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="add"
|
||||
workspaceId="ws-1"
|
||||
defaultScope="LOCAL"
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByLabelText(/Content/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add memory$/i }));
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/empty/i);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit mode", () => {
|
||||
it("PATCHes only changed fields", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText(/Content/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "rewritten content" } });
|
||||
// namespace untouched
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
|
||||
await waitFor(() => expect(mockPatch).toHaveBeenCalledTimes(1));
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories/mem-x",
|
||||
{ content: "rewritten content" },
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("no-op edit short-circuits (no PATCH fires) and still closes", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends namespace too when both content and namespace changed", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/Content/i), {
|
||||
target: { value: "newer content" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Namespace/i), {
|
||||
target: { value: "blockers" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
await waitFor(() => expect(mockPatch).toHaveBeenCalledTimes(1));
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories/mem-x",
|
||||
{ content: "newer content", namespace: "blockers" },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces save error and keeps the modal open", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
mockPatch.mockRejectedValueOnce(new Error("boom"));
|
||||
render(
|
||||
<MemoryEditorDialog
|
||||
open
|
||||
mode="edit"
|
||||
workspaceId="ws-1"
|
||||
entry={SAMPLE}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/Content/i), {
|
||||
target: { value: "rewritten content" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save changes/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/boom/),
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MemoryInspectorPanel tests — issue #909
|
||||
* MemoryInspectorPanel — v2 redesign tests.
|
||||
*
|
||||
* Covers: loading, empty state, scope tabs, namespace filter,
|
||||
* entry list, expand, delete flow, optimistic updates, Refresh, semantic search.
|
||||
* Coverage targets every behavior the panel surfaces:
|
||||
* - Initial load wires GET /v2/namespaces + GET /v2/memories
|
||||
* - Plugin-unavailable banner (503) renders + disables interactions
|
||||
* - Generic error renders in the error banner
|
||||
* - Namespace dropdown populates from /v2/namespaces.readable; "All
|
||||
* namespaces" is the default
|
||||
* - Selecting a namespace re-fetches with ?namespace=...
|
||||
* - Search input debounces + scopes the request to ?q=
|
||||
* - Search results sort by score descending
|
||||
* - Empty-state copy differs by query / plugin-state / no-data
|
||||
* - Per-row badges render (kind / source / pin / TTL / score /
|
||||
* score) and TTL countdown handles past/future/null
|
||||
* - Delete (Forget) flow: optimistic removal, confirmation dialog,
|
||||
* server failure rolls back via reload
|
||||
* - formatTTL helper covers s/m/h/d/expired/null/invalid branches
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
@@ -18,7 +31,7 @@ vi.mock("@/lib/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
vi.mock('@/components/ConfirmDialog', () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
title,
|
||||
@@ -33,435 +46,473 @@ vi.mock("@/components/ConfirmDialog", () => ({
|
||||
confirmVariant?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
singleButton?: boolean;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<p data-testid="dialog-title">{title}</p>
|
||||
<p data-testid="dialog-message">{message}</p>
|
||||
<button onClick={onConfirm}>Confirm Delete</button>
|
||||
<button onClick={onCancel}>Cancel Delete</button>
|
||||
<button onClick={onConfirm}>Confirm</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { MemoryInspectorPanel } from "../MemoryInspectorPanel";
|
||||
|
||||
// ── Typed mock helpers ────────────────────────────────────────────────────────
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
MemoryInspectorPanel,
|
||||
formatTTL,
|
||||
isPluginUnavailableError,
|
||||
type MemoryV2,
|
||||
type NamespacesResponse,
|
||||
} from '../MemoryInspectorPanel';
|
||||
|
||||
const mockGet = vi.mocked(api.get);
|
||||
const mockDel = vi.mocked(api.del);
|
||||
|
||||
// ── Sample fixtures ───────────────────────────────────────────────────────────
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NOW = "2026-04-17T12:00:00.000Z";
|
||||
|
||||
const MEMORY_A: import("../MemoryInspectorPanel").MemoryEntry = {
|
||||
id: "mem-a",
|
||||
workspace_id: "ws-1",
|
||||
content: "Remember to review PRs before merging",
|
||||
scope: "LOCAL",
|
||||
namespace: "general",
|
||||
created_at: NOW,
|
||||
const NS_RESPONSE: NamespacesResponse = {
|
||||
readable: [
|
||||
{ name: 'workspace:ws-1', kind: 'workspace', label: 'Workspace (ws-1)' },
|
||||
{ name: 'team:t-1', kind: 'team', label: 'Team (t-1)' },
|
||||
],
|
||||
writable: [{ name: 'workspace:ws-1', kind: 'workspace', label: 'Workspace (ws-1)' }],
|
||||
};
|
||||
|
||||
const MEMORY_B: import("../MemoryInspectorPanel").MemoryEntry = {
|
||||
id: "mem-b",
|
||||
workspace_id: "ws-1",
|
||||
content: "Team knowledge: deploy happens on Fridays",
|
||||
scope: "TEAM",
|
||||
namespace: "procedures",
|
||||
created_at: NOW,
|
||||
const MEM_BASIC: MemoryV2 = {
|
||||
id: 'mem-a',
|
||||
namespace: 'workspace:ws-1',
|
||||
content: 'Remember the standup is at 10am',
|
||||
kind: 'fact',
|
||||
source: 'agent',
|
||||
pin: false,
|
||||
created_at: '2026-04-17T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const TWO_MEMORIES = [MEMORY_A, MEMORY_B];
|
||||
const MEM_PINNED: MemoryV2 = {
|
||||
id: 'mem-pinned',
|
||||
namespace: 'team:t-1',
|
||||
content: 'Team retro every Friday',
|
||||
kind: 'summary',
|
||||
source: 'user',
|
||||
pin: true,
|
||||
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
created_at: '2026-04-17T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const MEM_RUNTIME_CHECKPOINT: MemoryV2 = {
|
||||
id: 'mem-checkpoint',
|
||||
namespace: 'team:t-1',
|
||||
content: 'Runtime checkpoint',
|
||||
kind: 'checkpoint',
|
||||
source: 'runtime',
|
||||
pin: false,
|
||||
created_at: '2026-04-17T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const MEM_EXPIRED: MemoryV2 = {
|
||||
id: 'mem-expired',
|
||||
namespace: 'workspace:ws-1',
|
||||
content: 'Stale memory',
|
||||
kind: 'fact',
|
||||
source: 'agent',
|
||||
pin: false,
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(),
|
||||
created_at: '2026-04-17T12:00:00.000Z',
|
||||
};
|
||||
|
||||
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockReset();
|
||||
mockDel.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Helper: flush microtasks + React state updates ─────────────────────────────
|
||||
async function flushUpdates(): Promise<void> {
|
||||
await act(async () => {});
|
||||
// Helper: stub a basic two-call flow (namespaces + memories).
|
||||
function stubFetch(memories: MemoryV2[], namespaces: NamespacesResponse = NS_RESPONSE) {
|
||||
mockGet.mockImplementation(((url: string) => {
|
||||
if (url.includes('/v2/namespaces')) {
|
||||
return Promise.resolve(namespaces);
|
||||
}
|
||||
return Promise.resolve({ memories });
|
||||
}) as typeof api.get);
|
||||
}
|
||||
|
||||
// ── Loading & empty state ─────────────────────────────────────────────────────
|
||||
// ── isPluginUnavailableError helper ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — loading and empty state", () => {
|
||||
it("shows loading indicator before data arrives", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockReturnValue(new Promise(() => {}) as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
expect(screen.getByText(/loading memories/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders empty state when API returns []", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fetches from the correct workspace memories endpoint with scope=LOCAL", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-abc-123" />);
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-abc-123/memories?scope=LOCAL"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error banner when fetch throws", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"));
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("Network error")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scope tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — scope tabs", () => {
|
||||
it("renders LOCAL, TEAM, GLOBAL tabs", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByRole("button", { name: "LOCAL" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "TEAM" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "GLOBAL" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("LOCAL is active by default", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByRole("button", { name: "LOCAL" }).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicking TEAM tab re-fetches with scope=TEAM", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=TEAM"
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking GLOBAL tab re-fetches with scope=GLOBAL", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "GLOBAL" }));
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=GLOBAL"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows scope-specific empty state when switching tabs", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("No TEAM memories")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Namespace filter ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — namespace filter", () => {
|
||||
it("renders namespace filter input", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByLabelText("Filter by namespace")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes namespace param in API call when set", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
fireEvent.change(screen.getByLabelText("Filter by namespace"), {
|
||||
target: { value: "facts" },
|
||||
});
|
||||
// Advance past the 300ms debounce
|
||||
act(() => { vi.advanceTimersByTime(350); });
|
||||
await flushUpdates();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL&namespace=facts"
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Entry list ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — entry list", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_MEMORIES as any);
|
||||
});
|
||||
|
||||
it("renders a row for every memory", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText(/Remember to review PRs before merging/)).toBeTruthy();
|
||||
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays memory count in toolbar", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("2 memories")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays scope badge for each entry", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByTitle("Scope: LOCAL")).toBeTruthy();
|
||||
expect(screen.getByTitle("Scope: TEAM")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("entries are collapsed by default (pre region not visible)", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
// Expanded region (pre tag) should not exist in DOM yet
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Expand / collapse ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — expand/collapse", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_MEMORIES as any);
|
||||
});
|
||||
|
||||
it("clicking a row header expands it and shows the full content in a pre tag", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText(/Remember to review PRs before merging/).closest("button")!
|
||||
);
|
||||
await flushUpdates();
|
||||
// After expand, a region with the full content <pre> should appear
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking the header again collapses the row (pre region removed)", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
const headerBtn = screen
|
||||
.getByText(/Remember to review PRs before merging/)
|
||||
.closest("button")!;
|
||||
fireEvent.click(headerBtn); // expand
|
||||
await flushUpdates();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
|
||||
fireEvent.click(headerBtn); // collapse
|
||||
await flushUpdates();
|
||||
// After collapse, the region (pre) is removed from the DOM
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — delete flow", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_MEMORIES as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockDel.mockResolvedValue({ status: "deleted" } as any);
|
||||
});
|
||||
|
||||
/** Helper: expand memory-A and click its Delete button */
|
||||
async function openDeleteForMemoryA() {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
fireEvent.click(
|
||||
screen.getByText(/Remember to review PRs before merging/).closest("button")!
|
||||
);
|
||||
await flushUpdates();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete memory" }));
|
||||
await flushUpdates();
|
||||
}
|
||||
|
||||
it("opens ConfirmDialog when Delete is clicked", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
|
||||
expect(screen.getByTestId("dialog-title").textContent).toBe("Delete memory");
|
||||
});
|
||||
|
||||
it("calls api.del with the correct URL-encoded path on confirm", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
fireEvent.click(screen.getByText("Confirm Delete"));
|
||||
await flushUpdates();
|
||||
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1/memories/mem-a");
|
||||
});
|
||||
|
||||
it("removes the entry optimistically after confirm", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
fireEvent.click(screen.getByText("Confirm Delete"));
|
||||
await flushUpdates();
|
||||
expect(screen.queryByText(/Remember to review PRs before merging/)).toBeNull();
|
||||
// Sibling entry unaffected
|
||||
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes ConfirmDialog without deleting when Cancel is clicked", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
fireEvent.click(screen.getByText("Cancel Delete"));
|
||||
await flushUpdates();
|
||||
expect(screen.queryByTestId("confirm-dialog")).toBeNull();
|
||||
expect(mockDel).not.toHaveBeenCalled();
|
||||
// Sibling memory entry (MEMORY_B) is still in the list
|
||||
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — Refresh button", () => {
|
||||
it("re-fetches entries when Refresh is clicked", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh memories" }));
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── role=alert a11y ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — error elements have role=alert", () => {
|
||||
it("fetch error banner has role='alert'", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"));
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert.textContent).toContain("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Semantic search ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — semantic search", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("debounces search input by 300ms before calling API", async () => {
|
||||
vi.useFakeTimers();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search memories"), {
|
||||
target: { value: "deploy" },
|
||||
});
|
||||
|
||||
// 200ms — debounce has NOT fired yet
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
await flushUpdates();
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
|
||||
// 350ms total — debounce fires
|
||||
act(() => { vi.advanceTimersByTime(150); });
|
||||
await flushUpdates();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders similarity-badge when entry has similarity_score", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([{ ...MEMORY_A, similarity_score: 0.87 }] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
const badge = document.querySelector('[data-testid="similarity-badge"]');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge?.textContent).toBe("87%");
|
||||
});
|
||||
|
||||
it("does not render similarity-badge when entry has no similarity_score", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([MEMORY_A] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
describe('isPluginUnavailableError', () => {
|
||||
it('matches the literal env var contract from the server handler', () => {
|
||||
expect(
|
||||
document.querySelector('[data-testid="similarity-badge"]')
|
||||
).toBeNull();
|
||||
isPluginUnavailableError(
|
||||
new Error('API GET /workspaces/x/v2/memories: 503 {"error":"memory plugin is not configured (set MEMORY_PLUGIN_URL)"}'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clear button resets query immediately and re-fetches without ?q=", async () => {
|
||||
vi.useFakeTimers();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
it('does not false-match on generic 503 errors that don\'t mention the env var', () => {
|
||||
expect(isPluginUnavailableError(new Error('API GET /foo: 503 something else'))).toBe(false);
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search memories"), {
|
||||
target: { value: "deploy" },
|
||||
it('does not false-match on plain 4xx errors', () => {
|
||||
expect(isPluginUnavailableError(new Error('API GET /foo: 401 unauthorized'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-Error inputs', () => {
|
||||
expect(isPluginUnavailableError(null)).toBe(false);
|
||||
expect(isPluginUnavailableError(undefined)).toBe(false);
|
||||
expect(isPluginUnavailableError('a string')).toBe(false);
|
||||
expect(isPluginUnavailableError({ message: 'MEMORY_PLUGIN_URL' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatTTL helper ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatTTL', () => {
|
||||
it('returns empty string for null/undefined/empty', () => {
|
||||
expect(formatTTL(null)).toBe('');
|
||||
expect(formatTTL(undefined)).toBe('');
|
||||
expect(formatTTL('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for invalid date strings', () => {
|
||||
expect(formatTTL('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns "expired" for past timestamps', () => {
|
||||
const past = new Date(Date.now() - 5000).toISOString();
|
||||
expect(formatTTL(past)).toBe('expired');
|
||||
});
|
||||
|
||||
it('formats <60s as seconds', () => {
|
||||
const future = new Date(Date.now() + 30_000).toISOString();
|
||||
expect(formatTTL(future)).toMatch(/^\d{1,2}s$/);
|
||||
});
|
||||
|
||||
it('formats <60m as minutes', () => {
|
||||
const future = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
expect(formatTTL(future)).toMatch(/^\d{1,2}m$/);
|
||||
});
|
||||
|
||||
it('formats <24h as hours', () => {
|
||||
const future = new Date(Date.now() + 5 * 3_600_000).toISOString();
|
||||
expect(formatTTL(future)).toMatch(/^\d{1,2}h$/);
|
||||
});
|
||||
|
||||
it('formats >24h as days', () => {
|
||||
const future = new Date(Date.now() + 3 * 86_400_000).toISOString();
|
||||
expect(formatTTL(future)).toMatch(/^\d{1,2}d$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initial load + dropdown ─────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — initial load', () => {
|
||||
it('fetches namespaces and memories on mount', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = mockGet.mock.calls.map((c) => c[0]);
|
||||
expect(calls.some((u) => u.includes('/v2/namespaces'))).toBe(true);
|
||||
expect(calls.some((u) => u.includes('/v2/memories'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the row contents from the memories response', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Remember the standup is at 10am/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the namespace dropdown with readable entries + "All namespaces"', async () => {
|
||||
stubFetch([]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Filter by namespace'));
|
||||
const select = screen.getByLabelText('Filter by namespace') as HTMLSelectElement;
|
||||
const optionLabels = Array.from(select.options).map((o) => o.textContent ?? '');
|
||||
expect(optionLabels[0]).toContain('All namespaces');
|
||||
expect(optionLabels.join('|')).toContain('Workspace (ws-1)');
|
||||
expect(optionLabels.join('|')).toContain('Team (t-1)');
|
||||
});
|
||||
|
||||
it('selecting a namespace re-fetches with ?namespace=', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Filter by namespace'));
|
||||
|
||||
const select = screen.getByLabelText('Filter by namespace') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'team:t-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = mockGet.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((u) => u.includes('namespace=team%3At-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plugin unavailable (503) ────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — plugin unavailable', () => {
|
||||
it('renders the operator-hint banner and disables search input', async () => {
|
||||
mockGet.mockRejectedValue(new Error('HTTP 503: memory plugin is not configured (set MEMORY_PLUGIN_URL)'));
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByTestId('plugin-unavailable-banner'));
|
||||
|
||||
const searchInput = screen.getByLabelText('Search memories') as HTMLInputElement;
|
||||
expect(searchInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the empty-state explaining plugin disabled', async () => {
|
||||
mockGet.mockRejectedValue(new Error('API GET /workspaces/x/v2/memories: 503 {"error":"memory plugin is not configured (set MEMORY_PLUGIN_URL)"}'));
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText(/Memory plugin disabled/i));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Generic error (non-503) ─────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — generic errors', () => {
|
||||
it('surfaces a non-503 error in the error banner', async () => {
|
||||
mockGet.mockImplementation(((url: string) => {
|
||||
if (url.includes('/v2/namespaces')) {
|
||||
return Promise.resolve(NS_RESPONSE);
|
||||
}
|
||||
return Promise.reject(new Error('upstream timeout'));
|
||||
}) as typeof api.get);
|
||||
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
// Error banner has role=alert
|
||||
const alerts = screen.getAllByRole('alert');
|
||||
const found = alerts.some((a) => a.textContent?.includes('upstream timeout'));
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — search', () => {
|
||||
it('eventually fires query with ?q= after debounce', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Search memories'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Search memories'), {
|
||||
target: { value: 'standup' },
|
||||
});
|
||||
|
||||
act(() => { vi.advanceTimersByTime(350); });
|
||||
await flushUpdates();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
|
||||
await waitFor(
|
||||
() => {
|
||||
const calls = mockGet.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((u) => u.includes('q=standup'))).toBe(true);
|
||||
},
|
||||
{ timeout: 1500 },
|
||||
);
|
||||
mockGet.mockClear();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
await flushUpdates();
|
||||
it('sorts results by score descending when query active', async () => {
|
||||
const lowScore: MemoryV2 = { ...MEM_BASIC, id: 'low', score: 0.2, content: 'low' };
|
||||
const highScore: MemoryV2 = { ...MEM_BASIC, id: 'high', score: 0.95, content: 'high' };
|
||||
// Plugin returns in arbitrary order; component sorts.
|
||||
mockGet.mockImplementation(((url: string) => {
|
||||
if (url.includes('/v2/namespaces')) return Promise.resolve(NS_RESPONSE);
|
||||
return Promise.resolve({ memories: [lowScore, highScore] });
|
||||
}) as typeof api.get);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL"
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Search memories'));
|
||||
fireEvent.change(screen.getByLabelText('Search memories'), {
|
||||
target: { value: 'something' },
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const rows = screen.getAllByTestId(/^memory-row-/);
|
||||
// First row should be the high-score one
|
||||
expect(rows[0].getAttribute('data-testid')).toBe('memory-row-high');
|
||||
},
|
||||
{ timeout: 1500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clear-button resets the query', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Search memories'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Search memories'), {
|
||||
target: { value: 'foo' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('Clear search'));
|
||||
expect((screen.getByLabelText('Search memories') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('renders no-results empty-state when search has no matches', async () => {
|
||||
stubFetch([]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByLabelText('Search memories'));
|
||||
fireEvent.change(screen.getByLabelText('Search memories'), {
|
||||
target: { value: 'nothing' },
|
||||
});
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(/No memories match your search/i)).toBeTruthy();
|
||||
},
|
||||
{ timeout: 1500 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-row badges ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — row badges', () => {
|
||||
it('renders kind, source, pin, TTL badges per shape', async () => {
|
||||
stubFetch([MEM_PINNED, MEM_RUNTIME_CHECKPOINT]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Pinned memory: kind=summary, source=user, pin=true, TTL>0
|
||||
const pinnedRow = screen.getByTestId('memory-row-mem-pinned');
|
||||
expect(pinnedRow.querySelector('[data-testid="kind-badge"]')?.textContent).toBe('S');
|
||||
expect(pinnedRow.querySelector('[data-testid="source-badge"]')?.textContent).toBe('user');
|
||||
expect(pinnedRow.querySelector('[data-testid="pin-badge"]')).toBeTruthy();
|
||||
expect(pinnedRow.querySelector('[data-testid="ttl-badge"]')?.textContent).toMatch(/^⌛\d+[hd]$/);
|
||||
|
||||
// Checkpoint memory: kind=checkpoint, source=runtime, no pin, no TTL
|
||||
const propRow = screen.getByTestId('memory-row-mem-checkpoint');
|
||||
expect(propRow.querySelector('[data-testid="kind-badge"]')?.textContent).toBe('C');
|
||||
expect(propRow.querySelector('[data-testid="source-badge"]')?.textContent).toBe('runtime');
|
||||
expect(propRow.querySelector('[data-testid="pin-badge"]')).toBeNull();
|
||||
expect(propRow.querySelector('[data-testid="ttl-badge"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('TTL badge shows "expired" for past expires_at', async () => {
|
||||
stubFetch([MEM_EXPIRED]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
const row = screen.getByTestId('memory-row-mem-expired');
|
||||
expect(row.querySelector('[data-testid="ttl-badge"]')?.textContent).toBe('⌛expired');
|
||||
});
|
||||
});
|
||||
|
||||
it('expanding a row shows full content + Forget button', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByTestId('memory-row-mem-a'));
|
||||
|
||||
const row = screen.getByTestId('memory-row-mem-a');
|
||||
const headerButton = row.querySelector('button');
|
||||
expect(headerButton).toBeTruthy();
|
||||
fireEvent.click(headerButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Forget memory')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete (Forget) flow ──────────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — forget flow', () => {
|
||||
it('opens the confirm dialog on Forget click and removes optimistically on confirm', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
mockDel.mockResolvedValue({ status: 'deleted' });
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
|
||||
// Expand row, click Forget
|
||||
await waitFor(() => screen.getByTestId('memory-row-mem-a'));
|
||||
const row = screen.getByTestId('memory-row-mem-a');
|
||||
fireEvent.click(row.querySelector('button')!);
|
||||
await waitFor(() => screen.getByLabelText('Forget memory'));
|
||||
fireEvent.click(screen.getByLabelText('Forget memory'));
|
||||
|
||||
// Dialog appears with v2-shaped copy (Forget, not Delete)
|
||||
expect(screen.getByTestId('dialog-title').textContent).toBe('Forget memory');
|
||||
fireEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
// Optimistic removal happens immediately
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('memory-row-mem-a')).toBeNull();
|
||||
});
|
||||
// DELETE called with the right path
|
||||
await waitFor(() => {
|
||||
const delPaths = mockDel.mock.calls.map((c) => c[0] as string);
|
||||
expect(delPaths.some((p) => p.includes('/v2/memories/mem-a'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('cancelling the dialog leaves the row in place', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByTestId('memory-row-mem-a'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-row-mem-a').querySelector('button')!);
|
||||
await waitFor(() => screen.getByLabelText('Forget memory'));
|
||||
fireEvent.click(screen.getByLabelText('Forget memory'));
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(screen.queryByTestId('memory-row-mem-a')).toBeTruthy();
|
||||
expect(mockDel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back on server failure by reloading entries', async () => {
|
||||
stubFetch([MEM_BASIC]);
|
||||
mockDel.mockRejectedValue(new Error('upstream 502'));
|
||||
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByTestId('memory-row-mem-a'));
|
||||
fireEvent.click(screen.getByTestId('memory-row-mem-a').querySelector('button')!);
|
||||
await waitFor(() => screen.getByLabelText('Forget memory'));
|
||||
fireEvent.click(screen.getByLabelText('Forget memory'));
|
||||
fireEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
// After failure, error banner surfaces + reload re-fetches memories
|
||||
await waitFor(() => {
|
||||
const alerts = screen.getAllByRole('alert');
|
||||
const found = alerts.some((a) => a.textContent?.includes('upstream 502'));
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty state when no memories at all ────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — empty state', () => {
|
||||
it('renders the "no memories yet" empty state when not searching', async () => {
|
||||
stubFetch([]);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No memories yet')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MemoryInspectorPanel — refresh', () => {
|
||||
it('Refresh button refetches memories', async () => {
|
||||
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;
|
||||
fireEvent.click(screen.getByLabelText('Refresh memories'));
|
||||
|
||||
await waitFor(() => {
|
||||
const after = mockGet.mock.calls.filter((c) =>
|
||||
(c[0] as string).includes('/v2/memories'),
|
||||
).length;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConversationTraceModal } from "@/components/ConversationTraceModal";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { type ActivityEntry } from "@/types/activity";
|
||||
import { useWorkspaceName } from "@/hooks/useWorkspaceName";
|
||||
import { inferA2AErrorHint } from "./chat/a2aErrorHint";
|
||||
@@ -48,6 +49,15 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
const [traceOpen, setTraceOpen] = useState(false);
|
||||
const resolveName = useWorkspaceName();
|
||||
|
||||
// Refs let the WS handler read the latest filter / autoRefresh
|
||||
// selection without re-subscribing on every state change. The bus
|
||||
// listener is registered exactly once per mount via useSocketEvent's
|
||||
// ref-internal pattern; subscriber-side filtering reads from these.
|
||||
const filterRef = useRef(filter);
|
||||
filterRef.current = filter;
|
||||
const autoRefreshRef = useRef(autoRefresh);
|
||||
autoRefreshRef.current = autoRefresh;
|
||||
|
||||
const loadActivities = useCallback(async () => {
|
||||
try {
|
||||
const typeParam = filter !== "all" ? `?type=${filter}` : "";
|
||||
@@ -66,11 +76,58 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
loadActivities();
|
||||
}, [loadActivities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(loadActivities, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadActivities, autoRefresh]);
|
||||
// Live-update path (issue #61 stage 3, replaces the 5s setInterval).
|
||||
// ACTIVITY_LOGGED events from this workspace prepend to the rendered
|
||||
// list — dedup by id so a server-side update + a poll reply don't
|
||||
// double-render the same row.
|
||||
//
|
||||
// Honours the user's autoRefresh toggle: when paused, live updates
|
||||
// are dropped until the user re-enables Live (or hits Refresh, which
|
||||
// re-bootstraps via loadActivities).
|
||||
//
|
||||
// Filter awareness: matches the server-side `?type=<filter>`
|
||||
// semantics so the panel doesn't show rows the user excluded.
|
||||
useSocketEvent((msg) => {
|
||||
if (!autoRefreshRef.current) return;
|
||||
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) || "";
|
||||
|
||||
const f = filterRef.current;
|
||||
if (f !== "all" && activityType !== f) return;
|
||||
|
||||
const entry: ActivityEntry = {
|
||||
id:
|
||||
(p.id as string) ||
|
||||
`ws-push-${msg.timestamp || Date.now()}-${msg.workspace_id}`,
|
||||
workspace_id: msg.workspace_id,
|
||||
activity_type: activityType,
|
||||
source_id: (p.source_id as string | null) ?? null,
|
||||
target_id: (p.target_id as string | null) ?? null,
|
||||
method: (p.method as string | null) ?? null,
|
||||
summary: (p.summary as string | null) ?? null,
|
||||
request_body: (p.request_body as Record<string, unknown> | null) ?? null,
|
||||
response_body:
|
||||
(p.response_body as Record<string, unknown> | null) ?? null,
|
||||
duration_ms: (p.duration_ms as number | null) ?? null,
|
||||
status: (p.status as string) || "ok",
|
||||
error_detail: (p.error_detail as string | null) ?? null,
|
||||
created_at:
|
||||
(p.created_at as string) ||
|
||||
msg.timestamp ||
|
||||
new Date().toISOString(),
|
||||
};
|
||||
|
||||
setActivities((prev) => {
|
||||
// Dedup by id — a row that arrived via the bootstrap fetch and
|
||||
// also fires ACTIVITY_LOGGED from a delayed server-side hook
|
||||
// must render exactly once.
|
||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
||||
return [entry, ...prev];
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
@@ -7,8 +7,9 @@ import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
|
||||
import { uploadChatFiles, downloadChatFile } from "./chat/uploads";
|
||||
import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews";
|
||||
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
||||
import { PendingAttachmentPill } from "./chat/AttachmentViews";
|
||||
import { AttachmentPreview } from "./chat/AttachmentPreview";
|
||||
import { extractFilesFromTask } from "./chat/message-parser";
|
||||
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
|
||||
import { appendActivityLine } from "./chat/activityLog";
|
||||
@@ -1061,14 +1062,85 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
: "dark:prose-invert dark:[--tw-prose-invert-body:theme(colors.zinc.100)] dark:[--tw-prose-invert-headings:theme(colors.white)] dark:[--tw-prose-invert-bold:theme(colors.white)] dark:[--tw-prose-invert-code:theme(colors.zinc.100)]"
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Default ReactMarkdown renders `<a href="...">`
|
||||
// with no target and no scheme handling, so:
|
||||
//
|
||||
// 1. http/https links navigate the canvas tab
|
||||
// itself away — user loses canvas state.
|
||||
// 2. workspace://, file://, and bare /workspace/
|
||||
// paths from agent-authored markdown produce
|
||||
// an unhandled-protocol click → browser ends
|
||||
// up at about:blank with no download (the
|
||||
// reported bug from 2026-05-05).
|
||||
//
|
||||
// Override: external URLs open in a new tab with
|
||||
// rel="noopener noreferrer"; in-container paths
|
||||
// route through downloadChatFile so the browser
|
||||
// gets a real Blob with proper auth headers.
|
||||
a: ({ href, children, ...rest }) => {
|
||||
const url = String(href ?? "");
|
||||
// Use the SSOT helper isPlatformAttachment so
|
||||
// the markdown link override and the chip
|
||||
// download path agree on which schemes need
|
||||
// auth-routed download. Pre-fix this list was
|
||||
// duplicated and missed `platform-pending:`,
|
||||
// producing about:blank for poll-mode uploads.
|
||||
if (isPlatformAttachment(url)) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Construct a synthetic ChatAttachment
|
||||
// and route through the same
|
||||
// authenticated download path the
|
||||
// download chips use. Filename is the
|
||||
// last path segment so Save-As prefills
|
||||
// sensibly.
|
||||
const name = url.split(/[\\/]/).pop() || "download";
|
||||
downloadChatFile(workspaceId, {
|
||||
uri: url,
|
||||
name,
|
||||
}).catch((err) => {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? `Download failed: ${err.message}`
|
||||
: "Download failed",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// External (http(s) / mailto / unknown scheme):
|
||||
// open in new tab so canvas state survives.
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className={`flex flex-wrap gap-1 ${msg.content ? "mt-1.5" : ""}`}>
|
||||
{msg.attachments.map((att, i) => (
|
||||
<AttachmentChip
|
||||
<AttachmentPreview
|
||||
key={`${msg.id}-${i}`}
|
||||
workspaceId={workspaceId}
|
||||
attachment={att}
|
||||
onDownload={downloadAttachment}
|
||||
tone={msg.role === "user" ? "user" : "agent"}
|
||||
@@ -1167,7 +1239,22 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
// IME-safe send: while a CJK / Japanese / Korean IME is
|
||||
// composing, Enter accepts the candidate selection — not a
|
||||
// newline, not a send. `e.nativeEvent.isComposing` is the
|
||||
// standard signal (modern WebKit/Blink/Gecko); the keyCode
|
||||
// 229 fallback covers older Safari / WebKit-based mobile
|
||||
// browsers that delay setting isComposing on the
|
||||
// composition-end Enter. Reported 2026-05-05: typing
|
||||
// Chinese with the system IME, pressing Enter to commit
|
||||
// a candidate would inadvertently send the half-typed
|
||||
// message.
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.nativeEvent.isComposing &&
|
||||
e.keyCode !== 229
|
||||
) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
@@ -262,6 +262,27 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
setOriginalProvider("");
|
||||
}
|
||||
|
||||
// Skip the config.yaml fetch entirely for runtimes that manage
|
||||
// their own config (external, hermes, etc.) — they don't have a
|
||||
// platform-side template, so the GET would 404. The catch block
|
||||
// below handles 404 gracefully, but issuing the request adds
|
||||
// browser-console noise + a wasted RTT on every open of the
|
||||
// Config tab for the affected workspaces. Reported on
|
||||
// production reno-stars 2026-05-05 (workspace runtime=external,
|
||||
// 404 on /files/config.yaml visible in the console even though
|
||||
// the form rendered correctly).
|
||||
if (RUNTIMES_WITH_OWN_CONFIG.has(wsMetadataRuntime)) {
|
||||
setConfig({
|
||||
...DEFAULT_CONFIG,
|
||||
runtime: wsMetadataRuntime,
|
||||
model: wsMetadataModel,
|
||||
...(wsMetadataModel ? { runtime_config: { model: wsMetadataModel } } : {}),
|
||||
...(wsMetadataTier !== null ? { tier: wsMetadataTier } : {}),
|
||||
} as ConfigData);
|
||||
setOriginalModel(wsMetadataModel);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/config.yaml`);
|
||||
const parsed = parseYaml(res.content);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { showToast } from "../Toaster";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
import { FilesToolbar } from "./FilesTab/FilesToolbar";
|
||||
import { FileTree } from "./FilesTab/FileTree";
|
||||
import { FileEditor } from "./FilesTab/FileEditor";
|
||||
import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
|
||||
import { useFilesApi } from "./FilesTab/useFilesApi";
|
||||
import { buildTree } from "./FilesTab/tree";
|
||||
|
||||
@@ -14,9 +16,40 @@ export type { TreeNode } from "./FilesTab/tree";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
/** Workspace metadata from the canvas store. Optional for back-compat
|
||||
* with any caller that still mounts <FilesTab workspaceId=.../> without
|
||||
* threading data through (legacy tests). When present, runtime gates
|
||||
* the early-return below. Mirrors TerminalTab's prop shape (#2830). */
|
||||
data?: WorkspaceNodeData;
|
||||
}
|
||||
|
||||
export function FilesTab({ workspaceId }: Props) {
|
||||
/** Runtimes whose filesystem the platform doesn't own. The canvas can't
|
||||
* list/read/write files on these — the agent runs on the user's own
|
||||
* hardware (mac laptop, mac mini, hermes-on-home-server) and reaches
|
||||
* the platform via the heartbeat-based polling Phase 30 layer.
|
||||
*
|
||||
* Keep narrow — only add a runtime here when its provisioner genuinely
|
||||
* has no platform-owned filesystem. Otherwise the user loses access to
|
||||
* a real surface (e.g. claude-code SaaS workspaces have files served
|
||||
* by ListFiles via EIC; they belong on the rendering path, not here). */
|
||||
const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
|
||||
|
||||
export function FilesTab({ workspaceId, data }: Props) {
|
||||
// Early-return for runtimes whose filesystem is not platform-owned.
|
||||
// Skips the whole useFilesApi hook + tree render below — without this,
|
||||
// mounting the tab for an external workspace would issue a GET that
|
||||
// the platform can technically answer (it reads its own DB row, not
|
||||
// the user's machine), but every result row is fictional. Showing
|
||||
// "0 files / No config files yet" reads as a bug. The placeholder
|
||||
// makes the absence intentional and points the user at the right
|
||||
// surface (Chat).
|
||||
if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const [root, setRoot] = useState("/configs");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
@@ -45,11 +78,36 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
downloadFileByPath,
|
||||
downloadAllFiles,
|
||||
uploadFiles,
|
||||
uploadDataTransferItems,
|
||||
deleteAllFiles,
|
||||
} = useFilesApi(workspaceId, root);
|
||||
|
||||
// PR-D: track whether the user is currently dragging files OVER
|
||||
// the root area (not over a specific subdir row). Used to show
|
||||
// the "Drop to upload to root" highlight on the tree column.
|
||||
const [rootDragHover, setRootDragHover] = useState(false);
|
||||
|
||||
const handleDropToTarget = (
|
||||
targetDir: string,
|
||||
items: DataTransferItemList,
|
||||
) => {
|
||||
// canDelete is the gate proxy — same constraint as the toolbar
|
||||
// Upload button (today only /configs is writable from the canvas
|
||||
// surface). Without this check, dropping on /home would post
|
||||
// through /workspaces/<id>/files/<path>, which the backend would
|
||||
// reject only after an HTTP round-trip. Fail fast.
|
||||
if (root !== "/configs") {
|
||||
setError(
|
||||
`Upload only allowed in /configs (current root: ${root}). Switch root or use Upload button.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void uploadDataTransferItems(items, targetDir);
|
||||
};
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
@@ -190,8 +248,46 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* File tree */}
|
||||
<div className="w-[180px] border-r border-line/40 overflow-y-auto shrink-0">
|
||||
{/* File tree column. PR-D: outer div is the drop zone for
|
||||
"drop on root" — when the user drags into the column area
|
||||
(not over a specific subdir row), the drop targets the
|
||||
current root directory. Subdirectory rows in <FileTree>
|
||||
stop propagation on their own drop event so a drop on
|
||||
/configs/skills doesn't ALSO fire root-area drop. */}
|
||||
<div
|
||||
className={`w-[180px] border-r border-line/40 overflow-y-auto shrink-0 transition-colors ${
|
||||
rootDragHover ? "bg-accent/10 outline outline-1 outline-accent/40 -outline-offset-2" : ""
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
// Only highlight + accept the drop when uploads are
|
||||
// actually allowed for the current root. Without this
|
||||
// check the user gets a misleading drag affordance,
|
||||
// drops, then sees the toolbar's "switch root" toast —
|
||||
// bad UX.
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
setRootDragHover(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
|
||||
setRootDragHover(false);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
setRootDragHover(false);
|
||||
if (e.dataTransfer.items?.length) {
|
||||
handleDropToTarget("", e.dataTransfer.items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* New file input */}
|
||||
{showNewFile && (
|
||||
<div className="px-2 py-1 border-b border-line/40">
|
||||
@@ -209,14 +305,27 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
|
||||
No config files yet
|
||||
{rootDragHover
|
||||
? "Drop to upload to root"
|
||||
: root === "/configs"
|
||||
? "No config files yet — drag files here to upload"
|
||||
: "No config files yet"}
|
||||
</div>
|
||||
) : (
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
selectedPath={selectedFile}
|
||||
onSelect={openFile}
|
||||
// Delete is currently gated to /configs to match the
|
||||
// toolbar's New / Upload / Clear affordances. Context
|
||||
// menu and inline ✕ both honour the gate. PR-A made the
|
||||
// backend EIC delete work on all roots — keeping the
|
||||
// canvas gate conservative until we want to expose
|
||||
// /home /workspace deletion intentionally.
|
||||
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
|
||||
onDownload={downloadFileByPath}
|
||||
canDelete={root === "/configs"}
|
||||
onDropToTarget={handleDropToTarget}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
loadingDir={loadingDir}
|
||||
|
||||
@@ -1,41 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type TreeNode, getIcon } from "./tree";
|
||||
import { FileTreeContextMenu, type MenuItem } from "./FileTreeContextMenu";
|
||||
|
||||
interface TreeCallbacks {
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
/** PR-C: right-click → Download. Files only — directories ignore. */
|
||||
onDownload: (path: string) => void;
|
||||
/** Whether the active root permits delete. Wire into the Delete
|
||||
* context-menu item's `disabled` flag so the user gets the same
|
||||
* affordance as the toolbar (which gates Clear/New on /configs). */
|
||||
canDelete: boolean;
|
||||
/** PR-D: drop files/folders from the OS onto this row. targetDir
|
||||
* is the directory path (relative to the active root) under which
|
||||
* the dropped contents should land; "" means root. */
|
||||
onDropToTarget?: (targetDir: string, items: DataTransferItemList) => void;
|
||||
expandedDirs: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
loadingDir: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileTree renders the workspace tree + owns the right-click context
|
||||
* menu (PR-C) and the drop-target hover state (PR-D). Lifting the
|
||||
* menu state here (vs each row) means only one menu open at a time —
|
||||
* opening a new row's menu auto-closes the prior one. Same UX as
|
||||
* VSCode / Theia.
|
||||
*/
|
||||
export function FileTree({
|
||||
nodes,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth = 0,
|
||||
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
|
||||
const [menu, setMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
} | null>(null);
|
||||
// PR-D: hover-target highlight state for drag-drop. Lifted next to
|
||||
// the menu state so both shared-across-rows interactions live in
|
||||
// one place.
|
||||
const [hoverDir, setHoverDir] = useState<string | null>(null);
|
||||
|
||||
const openContextMenu = (e: React.MouseEvent, node: TreeNode) => {
|
||||
e.preventDefault();
|
||||
// Items composed per-row so the available actions reflect the
|
||||
// node type (files get Open + Download; directories get Delete
|
||||
// only since "open a directory in the editor" doesn't apply
|
||||
// and "Export folder" is the toolbar's job).
|
||||
const items: MenuItem[] = [];
|
||||
if (!node.isDir) {
|
||||
items.push({
|
||||
id: "open",
|
||||
label: "Open",
|
||||
icon: "⤴",
|
||||
onClick: () => onSelect(node.path),
|
||||
});
|
||||
items.push({
|
||||
id: "download",
|
||||
label: "Download",
|
||||
icon: "↓",
|
||||
onClick: () => onDownload(node.path),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: "✕",
|
||||
destructive: true,
|
||||
disabled: !canDelete,
|
||||
onClick: () => onDelete(node.path),
|
||||
});
|
||||
setMenu({ x: e.clientX, y: e.clientY, items });
|
||||
};
|
||||
|
||||
// Single state lifted to the top-level tree; nested <FileTree>s
|
||||
// (rendered for expanded directories below) do NOT instantiate
|
||||
// their own menus or drop-targets — they call back via prop
|
||||
// drilling. This keeps "only one menu open" + "only one drop
|
||||
// target highlighted" as structural invariants rather than
|
||||
// render-order coincidences.
|
||||
const childCallbacks: TreeCallbacks = {
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => (
|
||||
<TreeItem
|
||||
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
openContextMenu={openContextMenu}
|
||||
hoverDir={hoverDir}
|
||||
setHoverDir={setHoverDir}
|
||||
depth={depth}
|
||||
{...childCallbacks}
|
||||
/>
|
||||
))}
|
||||
{menu && (
|
||||
<FileTreeContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={menu.items}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,22 +133,81 @@ function TreeItem({
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth,
|
||||
}: TreeCallbacks & { node: TreeNode; depth: number }) {
|
||||
openContextMenu,
|
||||
hoverDir,
|
||||
setHoverDir,
|
||||
}: TreeCallbacks & {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
openContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
|
||||
hoverDir: string | null;
|
||||
setHoverDir: (p: string | null) => void;
|
||||
}) {
|
||||
const isSelected = selectedPath === node.path;
|
||||
const expanded = expandedDirs.has(node.path);
|
||||
const isLoading = loadingDir === node.path;
|
||||
const isDropTarget = node.isDir && hoverDir === node.path;
|
||||
|
||||
// PR-D drag handlers — only directory rows are valid drop targets
|
||||
// (dropping a file ON another file is ambiguous; treat it as
|
||||
// dropping in the parent dir, which the root area handles). When a
|
||||
// drag enters a directory row, mark it the hover target. When the
|
||||
// cursor leaves to a non-child element, clear it. drop fires the
|
||||
// upload callback with the row's path.
|
||||
const dragProps = node.isDir && onDropToTarget
|
||||
? {
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
// preventDefault is REQUIRED to opt this element into the
|
||||
// drop target list — without it, browsers refuse to fire
|
||||
// the drop event regardless of the drop handler.
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
onDragEnter: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setHoverDir(node.path);
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent) => {
|
||||
// Only clear hover when leaving to an element OUTSIDE this
|
||||
// row — bare leave-events fire for every child crossed
|
||||
// (the icon, the label, the ✕ button). Without the
|
||||
// contains() check the highlight flickers.
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
|
||||
setHoverDir(null);
|
||||
}
|
||||
},
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHoverDir(null);
|
||||
if (e.dataTransfer.items?.length) {
|
||||
onDropToTarget(node.path, e.dataTransfer.items);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
|
||||
className={`group w-full flex items-center gap-1 px-2 py-0.5 text-left transition-colors cursor-pointer ${
|
||||
isDropTarget
|
||||
? "bg-accent/20 outline outline-1 outline-accent/60"
|
||||
: "hover:bg-surface-card/40"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
{...dragProps}
|
||||
>
|
||||
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||
<span className="text-[10px]">📁</span>
|
||||
@@ -82,6 +229,9 @@ function TreeItem({
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={canDelete}
|
||||
onDropToTarget={onDropToTarget}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
@@ -99,6 +249,7 @@ function TreeItem({
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
>
|
||||
<span className="text-[9px]">{getIcon(node.name, false)}</span>
|
||||
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* FileTreeContextMenu — VSCode-style right-click menu for a single
|
||||
* file-tree row. Pops at the cursor's viewport coords; dismisses on
|
||||
* outside-click, Esc, blur, or scroll.
|
||||
*
|
||||
* Why a custom component (no library): the menu is one of several
|
||||
* "small popovers" in canvas; pulling in a dnd / popover lib for one
|
||||
* surface adds 10x the bytes of this implementation. The patterns
|
||||
* (outside-click + Esc + portal-free fixed position) match the
|
||||
* ContextMenu used in canvas/Toolbar so the keyboard-nav muscle
|
||||
* memory is uniform.
|
||||
*
|
||||
* Items are rendered from a `MenuItem[]` so callers can add/remove
|
||||
* actions without touching this component (e.g. PR-D will add an
|
||||
* "Upload to this folder" item for directory rows).
|
||||
*
|
||||
* Accessibility:
|
||||
* - role="menu" + role="menuitem" so screen readers announce the
|
||||
* surface as a menu, not a generic div.
|
||||
* - First item gets autofocus so keyboard users can ↓/↑/Enter without
|
||||
* reaching for the mouse.
|
||||
* - Esc + outside-click + Tab dismisses; behaves like every other
|
||||
* menu the user has touched on the canvas.
|
||||
*/
|
||||
export interface MenuItem {
|
||||
/** Stable identifier for testing + analytics. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional left icon glyph; not load-bearing. */
|
||||
icon?: string;
|
||||
/** Destructive (rendered in red) — for Delete-class actions. */
|
||||
destructive?: boolean;
|
||||
/** Item-specific click handler. The menu auto-closes after onClick
|
||||
* fires so handlers don't have to call onClose themselves. */
|
||||
onClick: () => void;
|
||||
/** Disabled items render but don't fire onClick (useful for
|
||||
* Delete-on-non-/configs case where the caller wants to surface
|
||||
* the item but explain it's gated). Currently unused — placeholder
|
||||
* for future options. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Viewport-coordinate position of the cursor that opened the menu. */
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// First item gets initial focus for keyboard ↓/↑/Enter nav.
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
firstItemRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Outside-click + Esc dismiss. Per memory
|
||||
// (feedback_abort_controller_for_rerendered_listeners), use an
|
||||
// AbortController so re-mounts (caller toggles the menu) don't leak
|
||||
// listeners.
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
// Roving focus across .menuitem buttons. Doing this with
|
||||
// tabindex management because Tab / Shift+Tab leave the menu
|
||||
// (which is the right thing — the user is escaping the menu).
|
||||
e.preventDefault();
|
||||
const buttons = ref.current?.querySelectorAll<HTMLButtonElement>(
|
||||
"[role='menuitem']:not([disabled])",
|
||||
);
|
||||
if (!buttons || buttons.length === 0) return;
|
||||
const arr = Array.from(buttons);
|
||||
const cur = arr.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur + 1) % arr.length
|
||||
: (cur - 1 + arr.length) % arr.length;
|
||||
arr[next].focus();
|
||||
}
|
||||
};
|
||||
// `mousedown` (not `click`) so the menu dismisses BEFORE the
|
||||
// tree-row's click handler would fire — otherwise clicking
|
||||
// outside also selects a different row, which is not what the
|
||||
// user expected when "outside-click closes the menu".
|
||||
document.addEventListener("mousedown", onPointerDown, { signal: ctrl.signal });
|
||||
document.addEventListener("keydown", onKeyDown, { signal: ctrl.signal });
|
||||
// Scroll inside any ancestor also dismisses — the fixed-position
|
||||
// menu would otherwise stay anchored to viewport coords while the
|
||||
// row it points at scrolled away. Use capture so we catch scroll
|
||||
// on inner panels (FileTree's overflow-y-auto wrapper).
|
||||
document.addEventListener("scroll", onClose, { signal: ctrl.signal, capture: true });
|
||||
return () => ctrl.abort();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
aria-label="File actions"
|
||||
className="fixed z-[1000] min-w-[140px] py-1 bg-surface-elevated border border-line/60 rounded-md shadow-xl shadow-black/30 text-[11px]"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={i === 0 ? firstItemRef : undefined}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
className={
|
||||
item.destructive
|
||||
? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus:bg-red-900/30 focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
}
|
||||
>
|
||||
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NotAvailablePanel — full-tab placeholder for runtimes whose filesystem
|
||||
* the platform doesn't own (today: runtime === "external").
|
||||
*
|
||||
* Pre-fix the FilesTab tried to GET /workspaces/<id>/files for these
|
||||
* workspaces. The platform answered with [] (no rows in workspace_files
|
||||
* for an external workspace by definition), but the canvas rendered
|
||||
* "0 files / No config files yet" which reads identically to the SaaS
|
||||
* empty-listing bug fixed in PR-A. Showing an explicit placeholder
|
||||
* makes the absence intentional and routes the user toward the
|
||||
* supported surface (Chat) for these workspaces.
|
||||
*
|
||||
* Mirrors the same affordance TerminalTab adopted for runtimes without
|
||||
* a TTY in PR #2830 — uniform "feature-not-applicable" UX across tabs.
|
||||
*/
|
||||
export function NotAvailablePanel({ runtime }: { runtime: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-surface-sunken/30">
|
||||
{/* Folder-with-slash icon. Custom inline SVG so we don't depend
|
||||
on an icon set being present at canvas build-time (matches
|
||||
TerminalTab's NotAvailablePanel pattern). */}
|
||||
<svg
|
||||
width="72"
|
||||
height="72"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-ink-soft mb-4"
|
||||
>
|
||||
{/* Folder body */}
|
||||
<path
|
||||
d="M10 22 L10 56 a4 4 0 0 0 4 4 L58 60 a4 4 0 0 0 4 -4 L62 26 a4 4 0 0 0 -4 -4 L34 22 L28 16 L14 16 a4 4 0 0 0 -4 4 Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/* Diagonal cancel slash */}
|
||||
<path
|
||||
d="M14 14 L58 58"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
|
||||
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
|
||||
This workspace runs the{" "}
|
||||
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
|
||||
whose filesystem isn't owned by the platform. Use the Chat tab to
|
||||
interact with the agent directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the right-click context menu added in PR-C of issue #2999.
|
||||
// VSCode-style affordance: Open / Download / Delete on file rows,
|
||||
// Delete on directory rows. Delete is gated by `canDelete` (parent
|
||||
// only enables on /configs root, matching the toolbar's gate).
|
||||
//
|
||||
// Pinned branches:
|
||||
// 1. Right-click on a file row opens the menu at the click coords
|
||||
// with Open + Download + Delete items.
|
||||
// 2. Right-click on a directory row opens the menu with Delete
|
||||
// only (no Open/Download — directories don't have one-click
|
||||
// semantics in this surface).
|
||||
// 3. Clicking Download fires the onDownload callback with the
|
||||
// row's path.
|
||||
// 4. Clicking Delete fires onDelete with the row's path (when
|
||||
// canDelete=true).
|
||||
// 5. Delete is disabled in the rendered menu when canDelete=false
|
||||
// and clicking it does NOT fire onDelete (gate is real).
|
||||
// 6. Esc dismisses the menu.
|
||||
// 7. Click outside the menu dismisses it.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
|
||||
const dir: TreeNode = {
|
||||
name: "skills",
|
||||
path: "skills",
|
||||
isDir: true,
|
||||
children: [],
|
||||
size: 0,
|
||||
};
|
||||
|
||||
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
|
||||
const defaults = {
|
||||
nodes: [file, dir],
|
||||
selectedPath: null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null,
|
||||
};
|
||||
const merged = { ...defaults, ...props };
|
||||
return { ...render(<FileTree {...merged} />), props: merged };
|
||||
}
|
||||
|
||||
describe("FileTree right-click context menu", () => {
|
||||
it("right-click on a file row opens menu with Open/Download/Delete", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), {
|
||||
clientX: 50,
|
||||
clientY: 100,
|
||||
});
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Open/i })).not.toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Download/i })).not.toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("right-click on a directory row opens menu with Delete only (no Open/Download)", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 60, clientY: 120 });
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /Open/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /Download/i })).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Download fires onDownload with the row's path", () => {
|
||||
const { props } = renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Download/i }));
|
||||
expect(props.onDownload).toHaveBeenCalledWith("config.yaml");
|
||||
// Menu auto-closes after click.
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Delete fires onDelete with the row's path when canDelete=true", () => {
|
||||
const { props } = renderTree({ canDelete: true });
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Delete/i }));
|
||||
expect(props.onDelete).toHaveBeenCalledWith("config.yaml");
|
||||
});
|
||||
|
||||
it("Delete is disabled when canDelete=false; clicking does not fire onDelete", () => {
|
||||
const { props } = renderTree({ canDelete: false });
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
const del = screen.getByRole("menuitem", { name: /Delete/i }) as HTMLButtonElement;
|
||||
expect(del.disabled).toBe(true);
|
||||
fireEvent.click(del);
|
||||
expect(props.onDelete).not.toHaveBeenCalled();
|
||||
// Menu stays open on disabled click — same as VSCode (the user
|
||||
// can read the disabled-state hint without losing the menu).
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Esc dismisses the menu", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
act(() => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("click outside the menu dismisses it", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
|
||||
expect(screen.getByRole("menu")).not.toBeNull();
|
||||
// mousedown on document.body — outside the menu.
|
||||
act(() => {
|
||||
fireEvent.mouseDown(document.body);
|
||||
});
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("opening a second context menu replaces the first (only one open at a time)", () => {
|
||||
renderTree();
|
||||
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 10, clientY: 10 });
|
||||
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 20, clientY: 20 });
|
||||
// Only one menu in the DOM. The second open replaced the first
|
||||
// because the menu state is lifted to the FileTree, not per-row.
|
||||
const menus = screen.getAllByRole("menu");
|
||||
expect(menus.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the drag-drop upload added in PR-D of issue #2999.
|
||||
// Two layers of coverage:
|
||||
//
|
||||
// 1. The pure walker (collectFileEntries / walkEntry) — pins the
|
||||
// recursion shape against silent folder truncation. Browsers
|
||||
// return up to ~100 entries per readEntries() call; if the loop
|
||||
// stops early, large folder uploads silently drop files. We
|
||||
// simulate a multi-batch reader to discriminate.
|
||||
//
|
||||
// 2. FileTree directory-row drop handlers — pins that dragover/drop
|
||||
// events fire onDropToTarget with the directory's path + the
|
||||
// drop's DataTransferItemList.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
import { __testables } from "../useFilesApi";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ---- Walker tests ----
|
||||
|
||||
/**
|
||||
* Build a fake FileSystemEntry tree we can hand to walkEntry. The
|
||||
* shape mimics what webkitGetAsEntry returns from a real OS drag —
|
||||
* directory entries expose createReader, file entries expose file().
|
||||
*/
|
||||
function fakeFileEntry(name: string, content = "x"): {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file: (cb: (f: File) => void) => void;
|
||||
} {
|
||||
return {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
file: (cb) => cb(new File([content], name, { type: "text/plain" })),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDirEntry(
|
||||
name: string,
|
||||
childBatches: ReturnType<typeof fakeFileEntry>[][],
|
||||
): {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
createReader: () => { readEntries: (cb: (entries: unknown[]) => void) => void };
|
||||
} {
|
||||
let i = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb) => {
|
||||
// Mimic browser semantics: emit one batch per call, then
|
||||
// an empty array to signal end-of-stream. A walker that
|
||||
// calls readEntries only once would silently truncate at
|
||||
// the first batch.
|
||||
if (i < childBatches.length) {
|
||||
cb(childBatches[i++]);
|
||||
} else {
|
||||
cb([]);
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("walkEntry — folder-recursion drop walker", () => {
|
||||
it("collects a single dropped file", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
await __testables.walkEntry(fakeFileEntry("README.md") as never, "", out);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].relativePath).toBe("README.md");
|
||||
expect(out[0].file.name).toBe("README.md");
|
||||
});
|
||||
|
||||
it("walks a folder and preserves the relative path under the folder name", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const folder = fakeDirEntry("skills", [
|
||||
[fakeFileEntry("a.md"), fakeFileEntry("b.md")],
|
||||
]);
|
||||
await __testables.walkEntry(folder as never, "", out);
|
||||
expect(out.map((e) => e.relativePath).sort()).toEqual([
|
||||
"skills/a.md",
|
||||
"skills/b.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loops readEntries until empty so a multi-batch folder isn't truncated", async () => {
|
||||
// Browsers limit each readEntries() call to ~100 entries. Our
|
||||
// walker MUST call it again until an empty batch is returned.
|
||||
// Fake reader emits two batches of 2 + an implicit empty → 4
|
||||
// total. A buggy walker that only takes the first batch would
|
||||
// see only 2.
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const folder = fakeDirEntry("big", [
|
||||
[fakeFileEntry("1.txt"), fakeFileEntry("2.txt")],
|
||||
[fakeFileEntry("3.txt"), fakeFileEntry("4.txt")],
|
||||
]);
|
||||
await __testables.walkEntry(folder as never, "", out);
|
||||
expect(out.length).toBe(4);
|
||||
});
|
||||
|
||||
it("walks nested directories and accumulates the full path", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const inner = fakeDirEntry("web-search", [[fakeFileEntry("SKILL.md")]]);
|
||||
// Outer dir whose first batch contains a sub-dir entry.
|
||||
const outer = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: "skills",
|
||||
fullPath: "/skills",
|
||||
createReader: () => {
|
||||
let i = 0;
|
||||
return {
|
||||
readEntries: (cb: (entries: unknown[]) => void) => {
|
||||
if (i++ === 0) cb([inner]);
|
||||
else cb([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await __testables.walkEntry(outer as never, "", out);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].relativePath).toBe("skills/web-search/SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- FileTree drag-drop wiring ----
|
||||
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
|
||||
const skillsDir: TreeNode = { name: "skills", path: "skills", isDir: true, children: [], size: 0 };
|
||||
|
||||
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
|
||||
// PR-D test defaults must include PR-C's onDownload + canDelete now
|
||||
// that they're required on the TreeCallbacks shape (the rebase
|
||||
// surfaced this — the merged tree depends on both feature sets).
|
||||
const defaults: React.ComponentProps<typeof FileTree> = {
|
||||
nodes: [file, skillsDir],
|
||||
selectedPath: null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
onDropToTarget: vi.fn(),
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null,
|
||||
};
|
||||
const merged = { ...defaults, ...props };
|
||||
return { ...render(<FileTree {...merged} />), props: merged };
|
||||
}
|
||||
|
||||
describe("FileTree directory-row drag-drop", () => {
|
||||
it("dragover on a directory row preventDefault's so the drop will fire", () => {
|
||||
renderTree();
|
||||
const row = screen.getByText("skills");
|
||||
const dragOver = new Event("dragover", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(dragOver, "dataTransfer", {
|
||||
value: { dropEffect: "" },
|
||||
});
|
||||
row.parentElement!.dispatchEvent(dragOver);
|
||||
// preventDefault registers via the React handler — without it
|
||||
// the drop event would never fire, so this assertion is the
|
||||
// load-bearing one.
|
||||
expect(dragOver.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("drop on a directory row fires onDropToTarget with that path + the items list", () => {
|
||||
const { props } = renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
|
||||
fireEvent.drop(row, { dataTransfer: { items: fakeItems } });
|
||||
expect(props.onDropToTarget).toHaveBeenCalledWith("skills", fakeItems);
|
||||
});
|
||||
|
||||
it("drop on a FILE row does NOT fire onDropToTarget (only directories are valid targets)", () => {
|
||||
const { props } = renderTree();
|
||||
const fileRow = screen.getByText("config.yaml").parentElement!;
|
||||
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
|
||||
fireEvent.drop(fileRow, { dataTransfer: { items: fakeItems } });
|
||||
expect(props.onDropToTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drop with no DataTransferItems does NOT fire onDropToTarget", () => {
|
||||
const { props } = renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
fireEvent.drop(row, { dataTransfer: { items: { length: 0 } } });
|
||||
expect(props.onDropToTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dragenter sets the drop-target highlight on the directory row", () => {
|
||||
renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
fireEvent.dragEnter(row, { dataTransfer: {} });
|
||||
// Highlight class is the discriminator — without dragenter
|
||||
// wiring the row stays in its hover-only style.
|
||||
expect(row.className).toMatch(/bg-accent|outline-accent/);
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,43 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch a file's content from the server and trigger a browser
|
||||
* download. Used by the right-click "Download" context-menu item
|
||||
* (PR-C of issue #2999) — distinct from `handleDownloadFile` in
|
||||
* FilesTab which downloads the CURRENTLY-OPEN-IN-EDITOR file from
|
||||
* the in-memory `editContent` buffer (so unsaved edits round-trip
|
||||
* to disk). This helper downloads the on-server content, suitable
|
||||
* for arbitrary tree rows the user hasn't opened.
|
||||
*/
|
||||
const downloadFileByPath = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const res = await api.get<{ content: string }>(
|
||||
`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`,
|
||||
);
|
||||
// text/plain is correct for the canvas's text-only file
|
||||
// surface (config.yaml, prompts, skill markdown). Binary
|
||||
// files would need an Accept-arraybuffer path; the API
|
||||
// returns string today so this matches the wire shape.
|
||||
const blob = new Blob([res.content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = path.split("/").pop() || "file";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Downloaded ${a.download}`, "success");
|
||||
} catch (e) {
|
||||
showToast(
|
||||
`Download failed: ${e instanceof Error ? e.message : "unknown error"}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
[workspaceId, root],
|
||||
);
|
||||
|
||||
const downloadAllFiles = useCallback(async () => {
|
||||
const fileEntries = files.filter((f) => !f.dir);
|
||||
const results = await Promise.allSettled(
|
||||
@@ -114,16 +151,20 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
}, [files, workspaceId]);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
async (fileList: FileList, targetDir = "") => {
|
||||
let uploaded = 0;
|
||||
for (const file of Array.from(fileList)) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const parts = path.split("/");
|
||||
// For folder picker: webkitRelativePath is "<picked-folder>/a/b.txt"
|
||||
// — strip the picked-folder prefix so files land flat under the
|
||||
// workspace's target dir, not under a redundant outer folder.
|
||||
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
|
||||
const finalPath = targetDir ? `${targetDir}/${relPath}` : relPath;
|
||||
if (file.size > 1_000_000) continue;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
|
||||
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, { content });
|
||||
uploaded++;
|
||||
} catch {
|
||||
/* skip binary */
|
||||
@@ -131,7 +172,7 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(`Uploaded ${uploaded} files`, "success");
|
||||
showToast(`Uploaded ${uploaded} files${targetDir ? ` to ${targetDir}` : ""}`, "success");
|
||||
loadFiles();
|
||||
}
|
||||
return uploaded;
|
||||
@@ -139,6 +180,58 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
[workspaceId, loadFiles]
|
||||
);
|
||||
|
||||
/**
|
||||
* Upload files dragged from the OS via the HTML5 DataTransferItemList
|
||||
* API. Unlike the folder-picker path (uploadFiles), this preserves
|
||||
* the dropped folder structure under `targetDir` — drag a "skills/"
|
||||
* folder onto the /configs/skills row and you get
|
||||
* /configs/skills/skills/* (the OUTER folder name is preserved
|
||||
* because the user explicitly chose to drop a NAMED folder, unlike
|
||||
* the folder-picker which always wraps the picked dir).
|
||||
*
|
||||
* Walks FileSystemDirectoryEntry recursively via webkitGetAsEntry.
|
||||
* VSCode/JupyterLab use the same primitive — there's no other
|
||||
* portable browser API for "drag a folder from OS". `webkit*`
|
||||
* naming is a Chromium relic; Firefox + Safari implement the same
|
||||
* surface.
|
||||
*
|
||||
* Returns the number of files uploaded so the caller can show a
|
||||
* tally / fail toast.
|
||||
*/
|
||||
const uploadDataTransferItems = useCallback(
|
||||
async (items: DataTransferItemList, targetDir = "") => {
|
||||
const fileEntries = collectFileEntries(items);
|
||||
let uploaded = 0;
|
||||
for (const { file, relativePath } of await fileEntries) {
|
||||
if (file.size > 1_000_000) continue;
|
||||
const finalPath = targetDir
|
||||
? `${targetDir}/${relativePath}`
|
||||
: relativePath;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, {
|
||||
content,
|
||||
});
|
||||
uploaded++;
|
||||
} catch {
|
||||
/* skip binary */
|
||||
}
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(
|
||||
`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}${targetDir ? ` to ${targetDir}` : ""}`,
|
||||
"success",
|
||||
);
|
||||
loadFiles();
|
||||
}
|
||||
return uploaded;
|
||||
},
|
||||
[workspaceId, loadFiles],
|
||||
);
|
||||
|
||||
const deleteAllFiles = useCallback(async () => {
|
||||
let deleted = 0;
|
||||
for (const f of files) {
|
||||
@@ -165,8 +258,98 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
downloadFileByPath,
|
||||
downloadAllFiles,
|
||||
uploadFiles,
|
||||
uploadDataTransferItems,
|
||||
deleteAllFiles,
|
||||
};
|
||||
}
|
||||
|
||||
// ----- DataTransfer entry walker (PR-D) ---------------------------------
|
||||
|
||||
/**
|
||||
* Minimal subset of the FileSystem Entry API surface we use. The DOM
|
||||
* lib types this as FileSystemEntry / FileSystemFileEntry /
|
||||
* FileSystemDirectoryEntry but the relevant methods are callback-
|
||||
* based. Keep the shape narrow + explicit so the recursion below
|
||||
* type-checks without pulling in the full DOM lib types.
|
||||
*/
|
||||
interface FSEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?(success: (f: File) => void, fail?: (e: unknown) => void): void;
|
||||
createReader?(): { readEntries(success: (entries: FSEntry[]) => void): void };
|
||||
}
|
||||
|
||||
interface CollectedEntry {
|
||||
file: File;
|
||||
/** Path relative to the dropped root (e.g. "skills/web-search/SKILL.md"
|
||||
* for a dropped "skills/" folder containing web-search/SKILL.md). */
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a DataTransferItemList, returning every file entry as a flat
|
||||
* array keyed by the path relative to the originally-dropped item.
|
||||
* Folders dropped from the OS expand recursively; loose files
|
||||
* passthrough with name as the relative path.
|
||||
*
|
||||
* Skips items where webkitGetAsEntry() returns null — that's how
|
||||
* the browser signals a non-file payload (e.g. a dragged URL or
|
||||
* text snippet).
|
||||
*/
|
||||
async function collectFileEntries(
|
||||
items: DataTransferItemList,
|
||||
): Promise<CollectedEntry[]> {
|
||||
const out: CollectedEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind !== "file") continue;
|
||||
// webkitGetAsEntry is the standardised name; older Firefox used
|
||||
// getAsEntry. Both Chromium + Firefox + Safari ship the webkit-
|
||||
// prefixed variant today. There's no non-prefixed alternative.
|
||||
const entry = (item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FSEntry | null;
|
||||
}).webkitGetAsEntry?.();
|
||||
if (!entry) continue;
|
||||
await walkEntry(entry, "", out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function walkEntry(
|
||||
entry: FSEntry,
|
||||
prefix: string,
|
||||
out: CollectedEntry[],
|
||||
): Promise<void> {
|
||||
const name = entry.name;
|
||||
const relPath = prefix ? `${prefix}/${name}` : name;
|
||||
if (entry.isFile && entry.file) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
entry.file!(resolve, reject);
|
||||
});
|
||||
out.push({ file, relativePath: relPath });
|
||||
return;
|
||||
}
|
||||
if (entry.isDirectory && entry.createReader) {
|
||||
const reader = entry.createReader();
|
||||
// readEntries returns up to ~100 at a time on Chromium; loop
|
||||
// until empty so large folders aren't truncated.
|
||||
let batch: FSEntry[] = [];
|
||||
do {
|
||||
batch = await new Promise<FSEntry[]>((resolve) =>
|
||||
reader.readEntries(resolve),
|
||||
);
|
||||
for (const child of batch) {
|
||||
await walkEntry(child, relPath, out);
|
||||
}
|
||||
} while (batch.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for direct testing — the recursion + readEntries batching
|
||||
// is the part most likely to silently truncate a real folder upload.
|
||||
export const __testables = { collectFileEntries, walkEntry };
|
||||
|
||||
@@ -297,10 +297,49 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Compact-empty pattern: when the workspace has zero plugins
|
||||
// installed AND the registry isn't open, collapse the whole
|
||||
// "Plugins" section into a single inline pill rather than rendering
|
||||
// the full panel chrome. Reported on production 2026-05-05 (#2971):
|
||||
// the empty state's panel-with-zero-list-rows layout gives the user
|
||||
// a lot of vertical real estate for content that's just "0
|
||||
// installed + Install button". The compact form keeps that
|
||||
// affordance without the chrome.
|
||||
//
|
||||
// Expanded/full layout still fires when installed.length > 0 OR
|
||||
// when the user opens the registry (clicked "+ Install Plugin").
|
||||
// Once a plugin is installed the section auto-expands to surface
|
||||
// the list.
|
||||
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded;
|
||||
|
||||
if (compactEmpty) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 rounded-full border border-line/60 bg-surface-sunken/70 px-3 py-1.5"
|
||||
aria-label="Plugins (none installed)"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Plugins</span>
|
||||
<span className="text-[11px] text-ink-mid">0 installed</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(true)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
aria-expanded="false"
|
||||
aria-controls="plugins-section"
|
||||
>
|
||||
+ Install Plugin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Plugins section */}
|
||||
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
||||
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
|
||||
@@ -311,6 +350,8 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={() => setShowRegistry(!showRegistry)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
aria-expanded={showRegistry}
|
||||
aria-controls="plugins-registry"
|
||||
>
|
||||
{showRegistry ? "Hide Registry" : "+ Install Plugin"}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins two regressions reported on production 2026-05-05:
|
||||
//
|
||||
// 1. IME composition + Enter key: typing Chinese (or any CJK / IME-
|
||||
// composed text) and pressing Enter to commit the candidate
|
||||
// selection used to send the half-typed message. The fix checks
|
||||
// `event.nativeEvent.isComposing` (and a `keyCode === 229`
|
||||
// fallback for older WebKit) before treating Enter as send.
|
||||
//
|
||||
// 2. Markdown link clicks: the agent's ReactMarkdown-rendered links
|
||||
// used to:
|
||||
// - http/https → navigate canvas tab away (user lost canvas state)
|
||||
// - workspace://path / file:///workspace/... / /workspace/... →
|
||||
// browser hit about:blank (unhandled protocol).
|
||||
// Fix: external links get target="_blank" + noopener; in-container
|
||||
// paths route through downloadChatFile (same auth path as chips).
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock the api module so render doesn't try to talk to a real CP.
|
||||
const apiGet = vi.fn((_path: string): Promise<unknown> => Promise.resolve([]));
|
||||
const apiPost = vi.fn((_path: string, _body: unknown): Promise<unknown> => Promise.resolve({}));
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
post: (path: string, body: unknown) => apiPost(path, body),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector?: (s: unknown) => unknown) =>
|
||||
selector ? selector({ agentMessages: {}, consumeAgentMessages: () => [] }) : {},
|
||||
),
|
||||
}));
|
||||
|
||||
// Capture the downloadChatFile call so the markdown-link test can
|
||||
// assert in-container paths route through the authenticated download
|
||||
// path rather than the browser's bare anchor click.
|
||||
const downloadChatFileMock = vi.fn((_workspaceId: string, _att: { uri: string; name: string }) => Promise.resolve());
|
||||
vi.mock("../chat/uploads", async () => {
|
||||
const actual = await vi.importActual<typeof import("../chat/uploads")>("../chat/uploads");
|
||||
return {
|
||||
...actual,
|
||||
downloadChatFile: (workspaceId: string, att: { uri: string; name: string }) =>
|
||||
downloadChatFileMock(workspaceId, att),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockClear();
|
||||
apiPost.mockClear();
|
||||
downloadChatFileMock.mockClear();
|
||||
// jsdom doesn't implement scrollIntoView; ChatTab calls it after
|
||||
// every render with a new message.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
// Stub IntersectionObserver — the lazy-history sentinel uses it.
|
||||
class FakeIO {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
|
||||
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
|
||||
});
|
||||
|
||||
import { ChatTab } from "../ChatTab";
|
||||
|
||||
const minimalData = {
|
||||
status: "online" as const,
|
||||
runtime: "claude-code",
|
||||
currentTask: null,
|
||||
} as unknown as Parameters<typeof ChatTab>[0]["data"];
|
||||
|
||||
describe("ChatTab — IME-safe Enter key", () => {
|
||||
it("does NOT send the message when Enter fires during IME composition (isComposing)", async () => {
|
||||
render(<ChatTab workspaceId="ws-ime" data={minimalData} />);
|
||||
|
||||
// Find the textarea by its aria-label.
|
||||
const textarea = await screen.findByLabelText(/Message to agent/i);
|
||||
fireEvent.change(textarea, { target: { value: "你好" } });
|
||||
|
||||
// Simulate the Enter that commits an IME selection: isComposing=true.
|
||||
fireEvent.keyDown(textarea, { key: "Enter", isComposing: true });
|
||||
|
||||
// sendMessage POSTs via api.post; assert it was NOT called.
|
||||
await waitFor(() => {
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
// And the input is preserved — ChatTab clears it only on actual send.
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("你好");
|
||||
});
|
||||
|
||||
it("does NOT send when keyCode is 229 (older Safari IME fallback)", async () => {
|
||||
render(<ChatTab workspaceId="ws-ime2" data={minimalData} />);
|
||||
const textarea = await screen.findByLabelText(/Message to agent/i);
|
||||
fireEvent.change(textarea, { target: { value: "한국어" } });
|
||||
|
||||
// keyCode 229 is the older-Safari signal that an IME is composing.
|
||||
// Some mobile WebKit-based browsers delay setting isComposing on
|
||||
// the composition-end Enter; the keyCode fallback covers that.
|
||||
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("DOES send on a non-composing Enter (the happy path stays intact)", async () => {
|
||||
render(<ChatTab workspaceId="ws-ok" data={minimalData} />);
|
||||
const textarea = await screen.findByLabelText(/Message to agent/i);
|
||||
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" /* no isComposing, no 229 */ });
|
||||
|
||||
// The api.post for /a2a fires inside sendMessage. waitFor since
|
||||
// the call goes through several effects.
|
||||
await waitFor(() => {
|
||||
expect(apiPost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("Shift+Enter inserts newline regardless (no send)", async () => {
|
||||
render(<ChatTab workspaceId="ws-shift" data={minimalData} />);
|
||||
const textarea = await screen.findByLabelText(/Message to agent/i);
|
||||
fireEvent.change(textarea, { target: { value: "line 1" } });
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the "Files not available" early-return for runtimes whose
|
||||
// filesystem the platform doesn't own (today: runtime === "external").
|
||||
//
|
||||
// Pre-fix: FilesTab issued a GET /workspaces/<id>/files for every
|
||||
// workspace. The platform's response for an external workspace is
|
||||
// always [] (no rows in workspace_files), but the canvas rendered
|
||||
// "0 files / No config files yet" — visually identical to the SaaS
|
||||
// empty-listing bug fixed in PR-A. The placeholder makes the absence
|
||||
// intentional.
|
||||
//
|
||||
// Pinned branches:
|
||||
// 1. external runtime → "Files not available" banner renders,
|
||||
// runtime name surfaces in the body so user knows WHY.
|
||||
// 2. external runtime → useFilesApi is NOT invoked. Verified by
|
||||
// asserting the mocked api.get was never called.
|
||||
// 3. claude-code (or any other runtime) → no banner, normal mount
|
||||
// proceeds (`/configs` toolbar visible). Pre-fix regression cover.
|
||||
// 4. data prop omitted (legacy callers) → no early-return, falls
|
||||
// through to normal mount.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock the api module so the normal-mount branches don't try to
|
||||
// fetch against a real backend — and so we can assert the
|
||||
// external-runtime branch never fires a request.
|
||||
const apiCalls: string[] = [];
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn((path: string) => {
|
||||
apiCalls.push(path);
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
put: vi.fn(() => Promise.resolve()),
|
||||
del: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
// useCanvasStore is referenced by useFilesApi for the needsRestart
|
||||
// flag. The Toaster import inside FilesTab also pulls the store
|
||||
// indirectly. Stub minimally to satisfy the import chain.
|
||||
vi.mock("@/store/canvas", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/store/canvas")>(
|
||||
"@/store/canvas",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useCanvasStore: {
|
||||
getState: () => ({
|
||||
updateNodeData: vi.fn(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiCalls.length = 0;
|
||||
});
|
||||
|
||||
import { FilesTab } from "../FilesTab";
|
||||
|
||||
const externalData = { runtime: "external", status: "online" } as unknown as Parameters<
|
||||
typeof FilesTab
|
||||
>[0]["data"];
|
||||
|
||||
const claudeData = { runtime: "claude-code", status: "online" } as unknown as Parameters<
|
||||
typeof FilesTab
|
||||
>[0]["data"];
|
||||
|
||||
describe("FilesTab not-available early-return for runtimes without platform-owned filesystem", () => {
|
||||
it("external runtime renders the not-available banner with runtime name", () => {
|
||||
render(<FilesTab workspaceId="ws-ext" data={externalData} />);
|
||||
expect(screen.getByText(/Files not available/i)).not.toBeNull();
|
||||
// Runtime name must surface so the user understands WHY — without
|
||||
// it the placeholder reads as a generic error.
|
||||
expect(screen.getByText(/external/)).not.toBeNull();
|
||||
// Chat tab is the recommended alternative — flagged in copy so the
|
||||
// user knows where to go next instead of bouncing tabs.
|
||||
expect(screen.getByText(/Chat tab/i)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("external runtime does NOT issue any /files API call", async () => {
|
||||
render(<FilesTab workspaceId="ws-ext" data={externalData} />);
|
||||
// Tolerate one microtask boundary in case useEffect schedules.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const filesCalls = apiCalls.filter((p) => p.includes("/files"));
|
||||
expect(filesCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("claude-code runtime does NOT render the banner (normal mount)", async () => {
|
||||
render(<FilesTab workspaceId="ws-claude" data={claudeData} />);
|
||||
// The normal-mount path renders the FilesToolbar with the root
|
||||
// selector. Wait for it (useEffect → loadFiles → setLoading false).
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Files not available/i)).toBeNull();
|
||||
});
|
||||
// Toolbar's root selector confirms we're on the platform-owned
|
||||
// rendering path, not the placeholder.
|
||||
expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("data prop omitted falls through to normal mount (back-compat)", async () => {
|
||||
render(<FilesTab workspaceId="ws-no-data" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Files not available/i)).toBeNull();
|
||||
});
|
||||
// Without data we can't gate on runtime — must mount normally.
|
||||
expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the Edit affordance added to MemoryTab. Until this PR the Memory tab
|
||||
// was Add+Delete only; an entry that needed correction had to be deleted and
|
||||
// re-added — losing the version-counter and any in-flight optimistic-locking
|
||||
// invariants other writers depend on.
|
||||
//
|
||||
// Each test pins one branch of the new flow. If any fails, the bug is back.
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiDel = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
post: (path: string, body: unknown) => apiPost(path, body),
|
||||
del: (path: string) => apiDel(path),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { MemoryTab } from "../MemoryTab";
|
||||
|
||||
const sampleEntries = [
|
||||
{
|
||||
key: "team_brief",
|
||||
value: { goal: "ship v2" },
|
||||
version: 3,
|
||||
expires_at: null,
|
||||
updated_at: "2026-05-04T10:00:00Z",
|
||||
},
|
||||
{
|
||||
key: "plain_note",
|
||||
value: "raw text note",
|
||||
version: 1,
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
updated_at: "2026-05-04T10:01:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
apiDel.mockReset();
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === "/workspaces/ws-test/memory") {
|
||||
return Promise.resolve(sampleEntries);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||
});
|
||||
});
|
||||
|
||||
async function renderAndExpand(key: string) {
|
||||
render(<MemoryTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
// Reveal the Advanced section that hosts the entry list.
|
||||
const showAdvanced = await screen.findByRole("button", { name: "Show" });
|
||||
fireEvent.click(showAdvanced);
|
||||
// Expand the row.
|
||||
const row = await screen.findByRole("button", { name: new RegExp(key) });
|
||||
fireEvent.click(row);
|
||||
}
|
||||
|
||||
describe("MemoryTab Edit affordance", () => {
|
||||
it("Edit button appears once a row is expanded", async () => {
|
||||
await renderAndExpand("team_brief");
|
||||
expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("clicking Edit on a JSON-valued entry pre-fills the textarea with pretty JSON", async () => {
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = (await screen.findByLabelText(
|
||||
"Edit value for team_brief",
|
||||
)) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('{\n "goal": "ship v2"\n}');
|
||||
});
|
||||
|
||||
it("clicking Edit on a string-valued entry pre-fills raw (no surrounding quotes)", async () => {
|
||||
await renderAndExpand("plain_note");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = (await screen.findByLabelText(
|
||||
"Edit value for plain_note",
|
||||
)) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("raw text note");
|
||||
});
|
||||
|
||||
it("Save POSTs with if_match_version + parsed value, then reloads", async () => {
|
||||
apiPost.mockResolvedValue({ status: "ok", key: "team_brief", version: 4 });
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = await screen.findByLabelText("Edit value for team_brief");
|
||||
fireEvent.change(textarea, { target: { value: '{"goal":"ship v3"}' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/ws-test/memory", {
|
||||
key: "team_brief",
|
||||
value: { goal: "ship v3" },
|
||||
if_match_version: 3,
|
||||
});
|
||||
// Reload after save → second GET.
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("Save with non-JSON text falls back to plain string", async () => {
|
||||
apiPost.mockResolvedValue({ status: "ok" });
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = await screen.findByLabelText("Edit value for team_brief");
|
||||
fireEvent.change(textarea, { target: { value: "free-form note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
|
||||
expect(apiPost.mock.calls[0][1].value).toBe("free-form note");
|
||||
});
|
||||
|
||||
it("TTL field is forwarded as ttl_seconds when set", async () => {
|
||||
apiPost.mockResolvedValue({ status: "ok" });
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const ttlInput = await screen.findByLabelText("Edit TTL for team_brief");
|
||||
fireEvent.change(ttlInput, { target: { value: "3600" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
|
||||
expect(apiPost.mock.calls[0][1].ttl_seconds).toBe(3600);
|
||||
});
|
||||
|
||||
it("blank/zero/non-numeric TTL is omitted from the payload", async () => {
|
||||
apiPost.mockResolvedValue({ status: "ok" });
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const ttlInput = await screen.findByLabelText("Edit TTL for team_brief");
|
||||
// Junk + zero both must drop out — payload must not contain ttl_seconds.
|
||||
fireEvent.change(ttlInput, { target: { value: "abc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
|
||||
expect(apiPost.mock.calls[0][1]).not.toHaveProperty("ttl_seconds");
|
||||
});
|
||||
|
||||
it("Cancel discards edits and restores the rendered value", async () => {
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = await screen.findByLabelText("Edit value for team_brief");
|
||||
fireEvent.change(textarea, { target: { value: '{"goal":"discarded"}' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
// Editor is gone; the JSON pre-block is back.
|
||||
expect(screen.queryByLabelText("Edit value for team_brief")).toBeNull();
|
||||
expect(screen.getAllByText(/"goal": "ship v2"/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("409 response surfaces a retry hint and reloads", async () => {
|
||||
apiPost.mockRejectedValueOnce(
|
||||
new Error("HTTP 409: if_match_version mismatch"),
|
||||
);
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = await screen.findByLabelText("Edit value for team_brief");
|
||||
fireEvent.change(textarea, { target: { value: '{"goal":"ship v3"}' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/changed since you opened it/i);
|
||||
// Initial mount load + post-conflict reload.
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("non-409 error surfaces the message and does not reload", async () => {
|
||||
apiPost.mockRejectedValueOnce(new Error("boom"));
|
||||
await renderAndExpand("team_brief");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("boom");
|
||||
// Only the initial mount load — no retry reload.
|
||||
expect(apiGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("entry with no version omits if_match_version (back-compat with older shape)", async () => {
|
||||
// Pre-version-counter shape: drop the `version` field from the row.
|
||||
apiGet.mockReset();
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === "/workspaces/ws-test/memory") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
key: "old_entry",
|
||||
value: "legacy",
|
||||
expires_at: null,
|
||||
updated_at: "2026-05-04T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked: ${path}`));
|
||||
});
|
||||
apiPost.mockResolvedValue({ status: "ok" });
|
||||
|
||||
await renderAndExpand("old_entry");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
||||
const textarea = await screen.findByLabelText("Edit value for old_entry");
|
||||
fireEvent.change(textarea, { target: { value: "updated" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
|
||||
const payload = apiPost.mock.calls[0][1];
|
||||
expect(payload).not.toHaveProperty("if_match_version");
|
||||
expect(payload.value).toBe("updated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the compact-when-empty layout for the SkillsTab Plugins section
|
||||
// (issue #2971, reported on production 2026-05-05).
|
||||
//
|
||||
// Three states matter for layout:
|
||||
// 1. installed.length === 0 + registry closed + load completed → COMPACT pill
|
||||
// 2. installed.length > 0 → FULL panel + installed list
|
||||
// 3. registry open (showRegistry=true) → FULL panel + registry browser
|
||||
//
|
||||
// The compact-empty path is the new behavior; the other two were
|
||||
// pre-existing. This test pins all three so a future refactor that
|
||||
// over-collapses (showing compact when plugins are installed) or
|
||||
// over-expands (showing full panel on empty load) fails loudly.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string, opts?: unknown) => apiGet(path, opts),
|
||||
post: vi.fn(() => Promise.resolve({})),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
import { SkillsTab } from "../SkillsTab";
|
||||
|
||||
const minimalData = {
|
||||
status: "online" as const,
|
||||
runtime: "claude-code",
|
||||
currentTask: "",
|
||||
agentCard: undefined,
|
||||
} as unknown as Parameters<typeof SkillsTab>[0]["data"];
|
||||
|
||||
describe("SkillsTab Plugins compact-empty layout", () => {
|
||||
it("renders compact pill when installed.length === 0 and registry closed", async () => {
|
||||
// Both fetches return empty arrays — workspace is fresh, no plugins.
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path.endsWith("/plugins") || path === "/plugins" || path === "/plugins/sources") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<SkillsTab workspaceId="ws-fresh" data={minimalData} />);
|
||||
|
||||
// Wait for the installedLoaded gate to flip — without that the
|
||||
// component renders a "loading" state, not the compact pill.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Compact assertions: the rounded-xl panel chrome MUST NOT be in
|
||||
// the DOM (we'd see two "Plugins" labels — one in the header,
|
||||
// one in the pill — if the layout regressed to "always full
|
||||
// panel"). The compact form has exactly one "Plugins" label.
|
||||
const labels = screen.getAllByText("Plugins");
|
||||
expect(labels).toHaveLength(1);
|
||||
|
||||
// The full-panel chrome's id="plugins-section" should NOT be
|
||||
// rendered when we're in compact mode.
|
||||
expect(document.getElementById("plugins-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders full panel when installed.length > 0", async () => {
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path.endsWith("/plugins")) {
|
||||
return Promise.resolve([
|
||||
{ name: "memory-postgres", version: "1.0.0", description: "memory backend", supported_on_runtime: true },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<SkillsTab workspaceId="ws-installed" data={minimalData} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 installed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Full-panel chrome MUST be present — id pin.
|
||||
expect(document.getElementById("plugins-section")).not.toBeNull();
|
||||
// Compact pill ariaLabel MUST NOT be present.
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("expands to full panel when user clicks + Install Plugin from compact pill", async () => {
|
||||
apiGet.mockImplementation(() => Promise.resolve([]));
|
||||
render(<SkillsTab workspaceId="ws-expand" data={minimalData} />);
|
||||
|
||||
// Start compact — wait for the compact pill to settle so we click
|
||||
// the right button (initial render before installedLoaded flips
|
||||
// doesn't have either layout, and the post-load compact pill is
|
||||
// what we want to interact with).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
|
||||
});
|
||||
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
|
||||
expect(installBtn.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
// After click, registry opens → full panel renders. The compact
|
||||
// pill's aria-label should be gone; the full-panel id should
|
||||
// appear. Generous waitFor — a registry fetch may also fire in
|
||||
// the React effect chain, and we want to assert the compact →
|
||||
// full transition without racing it.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.getElementById("plugins-section")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT collapse to compact while initial load is pending (avoid flash)", () => {
|
||||
// Returning a never-resolving promise means installedLoaded stays
|
||||
// false. The compact pill MUST NOT render in this state — that
|
||||
// would flash compact → full as the load completes, which looks
|
||||
// janky. The component shows a loading shell instead (the
|
||||
// existing pre-fix behavior).
|
||||
apiGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<SkillsTab workspaceId="ws-loading" data={minimalData} />);
|
||||
|
||||
// Synchronous assertion — no waitFor — since we want to confirm
|
||||
// the compact pill is NOT rendered before any network round-trip
|
||||
// finishes.
|
||||
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentAudio — inline native HTML5 <audio controls> player for
|
||||
// chat attachments (RFC #2991, PR-2).
|
||||
//
|
||||
// Same auth + Blob-URL pattern as AttachmentImage / AttachmentVideo.
|
||||
// Native audio control bar handles play/pause/scrub/volume/download,
|
||||
// and there's no fullscreen UI to worry about (audio doesn't need
|
||||
// AttachmentLightbox).
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; src: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", src: href });
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", src: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 280, height: 40 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex flex-col gap-1 rounded-md border px-2 py-1 ${
|
||||
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
|
||||
}`}
|
||||
>
|
||||
{/* Filename label so the user knows what they're hearing
|
||||
before pressing play. Short, single-line, truncated. */}
|
||||
<span className="text-[10px] text-ink-mid truncate max-w-[280px]" title={attachment.name}>
|
||||
{attachment.name}
|
||||
</span>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
src={state.src}
|
||||
style={{ width: 280, height: 32 }}
|
||||
onError={() => setState({ kind: "error" })}
|
||||
>
|
||||
{attachment.name}
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentImage — inline image thumbnail + click-to-fullscreen.
|
||||
// First "specialized renderer" landing under RFC #2991 PR-1.
|
||||
//
|
||||
// Auth model
|
||||
// ----------
|
||||
//
|
||||
// The Critical UX/Security trade-off (per RFC's hostile-self-review
|
||||
// item #2): the bytes live behind workspace auth. A bare
|
||||
// <img src="https://reno-stars.../chat/download?path=…"> WILL NOT
|
||||
// include our cookie + Origin headers when the browser loads it —
|
||||
// even for same-origin canvas-server, the auth chain (cookie + token
|
||||
// + X-Molecule-Org-Slug header) is JS-injected, not browser-default.
|
||||
//
|
||||
// Solution: same auth path the chip download uses. Fetch the bytes
|
||||
// with the JS auth headers, wrap in a Blob, hand the browser an
|
||||
// ObjectURL. The image renders from local memory; no second request,
|
||||
// no auth leakage, no CORS pain.
|
||||
//
|
||||
// That same blob URL is what the lightbox shows on click — single
|
||||
// fetch, cached for the lifetime of the message bubble.
|
||||
//
|
||||
// Failure modes
|
||||
// -------------
|
||||
//
|
||||
// - Fetch fails (404, 403, network) → fall back to AttachmentChip
|
||||
// (the existing file-pill download flow). The user still gets a
|
||||
// working download; we just lose the inline preview.
|
||||
// - Decoded as non-image (server returned wrong Content-Type, or
|
||||
// bytes are corrupt) → onError handler swaps to AttachmentChip.
|
||||
// - Bytes too large — no enforcement here; the server caps at 25MB
|
||||
// per file (chat_files.go), which is too big for a thumbnail but
|
||||
// acceptable for a chat-attached image. If we hit pain we can
|
||||
// downscale via canvas, but defer that to v2.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; blobUrl: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const [open, setOpen] = useState(false);
|
||||
// Track whether we created the ObjectURL so cleanup runs on the
|
||||
// exact value we minted (state could change between effect setup
|
||||
// and effect cleanup if a new fetch fires).
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
// For non-platform URIs (http/https external image hosts) we can
|
||||
// skip the auth fetch — browser loads them directly. We bail out
|
||||
// of the auth-fetch flow and use the raw URL via resolveAttachmentHref.
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", blobUrl: href });
|
||||
return;
|
||||
}
|
||||
|
||||
// Platform-auth path: identical to downloadChatFile but we keep
|
||||
// the blob (don't trigger a Save-As). Use the same headers it does
|
||||
// by going through it indirectly — no, downloadChatFile triggers a
|
||||
// Save-As. Need a separate fetch.
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
// Read the same env var downloadChatFile reads — single source
|
||||
// of truth would be cleaner; refactor opportunity for PR-2 if
|
||||
// we add the same path to AttachmentVideo.
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", blobUrl: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Free the ObjectURL when the bubble unmounts — keeps memory
|
||||
// bounded across long chat histories.
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
// Failure → render the existing file chip. Maintains the download
|
||||
// affordance even if preview fails; the user never gets stuck.
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
|
||||
// Loading → small placeholder pill so the bubble doesn't reflow
|
||||
// when the image lands. Sized to roughly the thumbnail's aspect
|
||||
// ratio guess (a 240x180 box) so the layout is stable.
|
||||
if (state.kind === "loading" || state.kind === "idle") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 240, height: 180 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready → inline thumbnail with click handler. The img has its
|
||||
// own onError so a corrupt blob (server returned the right size
|
||||
// but invalid bytes) falls through to the chip too.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={`Preview ${attachment.name}`}
|
||||
className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
||||
tone === "user" ? "border-blue-400/30" : "border-line/50"
|
||||
}`}
|
||||
aria-label={`Open ${attachment.name} preview`}
|
||||
>
|
||||
<img
|
||||
src={state.blobUrl}
|
||||
alt={attachment.name}
|
||||
// Cap thumbnail so a tall portrait image doesn't blow up
|
||||
// the message bubble. The lightbox shows the full size.
|
||||
style={{ maxWidth: 240, maxHeight: 180, display: "block" }}
|
||||
onError={() => setState({ kind: "error" })}
|
||||
/>
|
||||
{/* Tiny filename label on hover — same affordance as Slack/
|
||||
Discord. Helps when several images land in one bubble. */}
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 truncate opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</button>
|
||||
<AttachmentLightbox
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
>
|
||||
<img
|
||||
src={state.blobUrl}
|
||||
alt={attachment.name}
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain"
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal helper — duplicated from uploads.ts (it's not exported
|
||||
// there). Kept local so this component doesn't reach into private
|
||||
// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
|
||||
// it, lift to an exported helper at that point (the third-caller
|
||||
// rule).
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
// Tenant subdomain shape: <slug>.moleculesai.app
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentLightbox — shared fullscreen modal for image / PDF /
|
||||
// (future) any-fullscreen-renderable kind. Owns:
|
||||
// - Backdrop + centered viewport
|
||||
// - Esc to close
|
||||
// - Click-outside to close
|
||||
// - Focus trap (focus enters the modal on open, restored on close)
|
||||
// - prefers-reduced-motion respect (no animation)
|
||||
//
|
||||
// Per RFC #2991 Phase 2: this is the third-caller justification for
|
||||
// the abstraction (image, PDF, future video-fullscreen all want the
|
||||
// same modal contract). Not invented for a single caller.
|
||||
//
|
||||
// Design choices:
|
||||
//
|
||||
// 1. Portals — we don't use ReactDOM.createPortal because the canvas
|
||||
// chat surface already renders at a high z-index and the modal's
|
||||
// fixed-position layout reaches the viewport regardless. Saves a
|
||||
// portal mount in the common case + avoids the SSR warning (canvas
|
||||
// is "use client" but the parent shell is server-rendered).
|
||||
//
|
||||
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
|
||||
// chat lightbox needs to trap focus only across two interactive
|
||||
// elements (close button + content), so a 100-line manual trap
|
||||
// beats pulling in focus-trap-react for ~12KB.
|
||||
//
|
||||
// 3. Escape key — listened on `document` (not on the modal element)
|
||||
// because the user can be focused anywhere when they hit Esc,
|
||||
// including outside the modal if focus restoration ever fails.
|
||||
// The cleanup runs on unmount so leaked listeners don't persist.
|
||||
|
||||
import { useEffect, useRef, useCallback, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
/** Render the lightbox when true. Caller controls open state. */
|
||||
open: boolean;
|
||||
/** Caller's handler for "close" — Esc, click-outside, X button. */
|
||||
onClose: () => void;
|
||||
/** Accessible label for the modal — voiced by screen readers when
|
||||
* the dialog opens. The caller knows what's inside (image alt
|
||||
* text, PDF filename) and supplies it. */
|
||||
ariaLabel: string;
|
||||
/** The thing being shown in fullscreen — <img>, <embed>, etc.
|
||||
* Caller is responsible for sizing it to fit the viewport (we
|
||||
* give it max-w-full max-h-full via CSS). */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Focus enters the close button on open + restores to whatever
|
||||
// had focus when the modal closes. Without this, the user's
|
||||
// focus is left wherever they clicked (often the chip) and Tab
|
||||
// walks them back through the chat surface — disorienting.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
closeButtonRef.current?.focus();
|
||||
return () => {
|
||||
previousFocusRef.current?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Esc closes; bound on document so the user can press Esc
|
||||
// regardless of where focus actually is.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Click on the backdrop (NOT the content) closes. Content's own
|
||||
// onClick stops propagation so the user can interact (e.g. native
|
||||
// PDF viewer controls) without dismissing the modal.
|
||||
const onBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
|
||||
onClick={onBackdropClick}
|
||||
>
|
||||
{/* Close button — top-right, large hit area, keyboard-focusable.
|
||||
ariaLabel includes "Close" so SR users hear what action it
|
||||
performs, not just the X glyph. */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
className="absolute top-4 right-4 rounded-full bg-white/10 hover:bg-white/20 text-white p-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 5l14 14M19 5l-14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentPDF — inline PDF preview using the browser's native viewer
|
||||
// (RFC #2991, PR-3).
|
||||
//
|
||||
// Why browser-native (not PDF.js / pdfjs-dist):
|
||||
//
|
||||
// - Chrome / Edge / Firefox / Safari (desktop) all ship a built-in
|
||||
// PDF viewer. <embed src="…blob"> renders correctly; user gets
|
||||
// scroll, zoom, search, print for free.
|
||||
// - PDF.js adds ~3 MB to the canvas bundle. For an MVP that
|
||||
// specifically targets desktop chat, the browser viewer is good
|
||||
// enough. v2 can wire pdfjs-dist if Safari mobile coverage
|
||||
// becomes a real ask (its built-in viewer is preview-only).
|
||||
//
|
||||
// Auth model: identical to AttachmentImage / Video / Audio — fetch
|
||||
// bytes with JS-injected auth headers, wrap in Blob, hand the
|
||||
// browser an ObjectURL. <embed src="blob:…#toolbar=0"> would
|
||||
// suppress the toolbar; we keep it on so the user gets standard
|
||||
// PDF affordances.
|
||||
//
|
||||
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
|
||||
// click. Same shared modal as image — third caller justifies the
|
||||
// abstraction (per RFC #2991 design).
|
||||
//
|
||||
// Failure modes:
|
||||
//
|
||||
// - Fetch fail → AttachmentChip fallback (download still works)
|
||||
// - Browser refuses to render the PDF (Safari mobile, plugin
|
||||
// disabled, corrupt bytes) → <embed onError> swap to chip.
|
||||
// Note: <embed> doesn't fire onError reliably across browsers.
|
||||
// Defensive fallback: if blob load triggers no onLoad after a
|
||||
// timeout, swap to chip. Implemented as a 3-second watchdog.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; blobUrl: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const [open, setOpen] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", blobUrl: href });
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", blobUrl: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse flex items-center gap-1.5 px-2 py-1 text-[10px] text-ink-mid"
|
||||
style={{ width: 240 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
>
|
||||
<PdfGlyph />
|
||||
Loading {attachment.name}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF preview chip — clicking it opens the full embed in the
|
||||
// shared lightbox. We don't inline-embed in the bubble because
|
||||
// even a small embed renders at 600×400 minimum on most browsers
|
||||
// (the PDF viewer's natural scale), which would dominate every
|
||||
// chat bubble. Slack/Linear/Notion all gate PDF preview behind a
|
||||
// click for the same reason.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={`Preview ${attachment.name}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] hover:bg-surface-card/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
||||
tone === "user"
|
||||
? "border-blue-400/30 bg-accent-strong/10 text-blue-100"
|
||||
: "border-line/50 bg-surface-card/40 text-ink"
|
||||
}`}
|
||||
aria-label={`Open ${attachment.name} preview`}
|
||||
>
|
||||
<PdfGlyph />
|
||||
<span className="truncate max-w-[200px]">{attachment.name}</span>
|
||||
<span className="opacity-60 shrink-0">PDF</span>
|
||||
</button>
|
||||
<AttachmentLightbox
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
>
|
||||
<embed
|
||||
src={state.blobUrl}
|
||||
type="application/pdf"
|
||||
// The lightbox's content slot caps at 95vw / 90vh, so size
|
||||
// 100% within that and let the user scroll inside the PDF
|
||||
// viewer.
|
||||
style={{ width: "95vw", height: "90vh" }}
|
||||
aria-label={attachment.name}
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="shrink-0 opacity-70"
|
||||
>
|
||||
<path
|
||||
d="M4 2h5l3 3v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path
|
||||
d="M5.5 9.5h1m1 0h1m-3 2h2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentPreview — the SSOT dispatch point for chat-attachment
|
||||
// rendering (RFC #2991, PR-1).
|
||||
//
|
||||
// Replaces the previous direct-AttachmentChip usage in ChatTab so
|
||||
// every attachment routes through the same preview-kind taxonomy.
|
||||
// Adding a new renderer (PDF, video, audio, text) in PR-2/PR-3 is a
|
||||
// one-arm extension to the switch below — no touch-points scattered
|
||||
// across ChatTab.tsx, AgentCommsPanel.tsx, or other chat consumers.
|
||||
//
|
||||
// Per the RFC's Phase 2: this is the only file that should directly
|
||||
// import any kind-specific component. ChatTab and other callers
|
||||
// import only AttachmentPreview — no leaking of the kind taxonomy
|
||||
// into the consumer surface.
|
||||
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { getAttachmentPreviewKind } from "./preview-kind";
|
||||
import { AttachmentImage } from "./AttachmentImage";
|
||||
import { AttachmentVideo } from "./AttachmentVideo";
|
||||
import { AttachmentAudio } from "./AttachmentAudio";
|
||||
import { AttachmentPDF } from "./AttachmentPDF";
|
||||
import { AttachmentTextPreview } from "./AttachmentTextPreview";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
/** Caller's download handler — used for the kind=file fallback
|
||||
* and as the kind-specific renderers' fallback when their own
|
||||
* preview fails (e.g. image fetch errored). */
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
/** Tone follows the message bubble's role — used for visual
|
||||
* variant only. */
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
export function AttachmentPreview({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const kind = getAttachmentPreviewKind(attachment.mimeType, attachment.uri, attachment.name);
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return (
|
||||
<AttachmentImage
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<AttachmentVideo
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<AttachmentAudio
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<AttachmentPDF
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<AttachmentTextPreview
|
||||
workspaceId={workspaceId}
|
||||
attachment={attachment}
|
||||
onDownload={onDownload}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
case "file":
|
||||
default:
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentTextPreview — inline preview for text/code/JSON/YAML/etc
|
||||
// (RFC #2991, PR-3).
|
||||
//
|
||||
// Shape: render first N lines (~10) in monospace inside the bubble.
|
||||
// Click "Show more" to expand fully; the lightbox is reserved for
|
||||
// image/PDF where viewport-size matters. For text, the bubble itself
|
||||
// can host the full content.
|
||||
//
|
||||
// Why no syntax highlighting (yet):
|
||||
//
|
||||
// - Pulling in shiki / highlight.js / prism adds 200-500KB to the
|
||||
// bundle for a feature that's nice-to-have. MVP uses plain
|
||||
// <pre><code>.
|
||||
// - Future: lazy-load shiki on first text-attachment render. v2
|
||||
// if the user reports the gap.
|
||||
//
|
||||
// Auth: same fetch+text() pattern as image/video/audio, but we read
|
||||
// the text directly instead of building a Blob URL — no <img>/<video>
|
||||
// element to feed.
|
||||
//
|
||||
// Memory: text files are usually small. We cap the preview at 256 KB
|
||||
// fetched (large logs would otherwise crash the bubble). If the file
|
||||
// exceeds the cap, we show what we got + a "truncated" note + a chip
|
||||
// to download the full file.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; text: string; truncated: boolean }
|
||||
| { kind: "error" };
|
||||
|
||||
const PREVIEW_LINE_COUNT = 10;
|
||||
const MAX_FETCH_BYTES = 256 * 1024; // 256 KB
|
||||
|
||||
export function AttachmentTextPreview({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
if (isPlatformAttachment(attachment.uri)) {
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
}
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
// Read up to MAX_FETCH_BYTES. Use the standard ReadableStream
|
||||
// path so we don't materialise a 100MB log into memory.
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
// Fallback: small text file, just .text() it.
|
||||
const text = await res.text();
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
kind: "ready",
|
||||
text: text.slice(0, MAX_FETCH_BYTES),
|
||||
truncated: text.length > MAX_FETCH_BYTES,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let received = 0;
|
||||
const chunks: BlobPart[] = [];
|
||||
while (received < MAX_FETCH_BYTES) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
// Copy into a fresh ArrayBuffer-backed view — TS in lib.dom
|
||||
// 2026 narrows BlobPart away from SharedArrayBuffer-backed
|
||||
// Uint8Arrays. Blob() accepts the copy fine at runtime.
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
chunks.push(copy.buffer);
|
||||
received += value.byteLength;
|
||||
}
|
||||
// If we hit the cap but the stream isn't done, mark truncated.
|
||||
const truncated = received >= MAX_FETCH_BYTES;
|
||||
if (truncated) reader.cancel();
|
||||
const blob = new Blob(chunks);
|
||||
const text = await blob.text();
|
||||
if (cancelled) return;
|
||||
setState({ kind: "ready", text, truncated });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 320, height: 80 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = state.text.split("\n");
|
||||
const preview = expanded ? state.text : lines.slice(0, PREVIEW_LINE_COUNT).join("\n");
|
||||
const showExpandButton = !expanded && lines.length > PREVIEW_LINE_COUNT;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block max-w-full rounded-md border ${
|
||||
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-line/40 text-[10px] text-ink-mid">
|
||||
<span className="truncate max-w-[220px]" title={attachment.name}>
|
||||
{attachment.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(attachment)}
|
||||
className="text-ink-soft hover:text-ink"
|
||||
title={`Download ${attachment.name}`}
|
||||
aria-label={`Download ${attachment.name}`}
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto px-2 py-1.5 text-[10px] leading-snug text-ink whitespace-pre font-mono max-w-[480px] max-h-[300px]">
|
||||
<code>{preview}</code>
|
||||
</pre>
|
||||
{showExpandButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="block w-full text-center text-[10px] text-ink-mid hover:text-ink py-1 border-t border-line/40"
|
||||
>
|
||||
Show all {lines.length} lines
|
||||
</button>
|
||||
)}
|
||||
{state.truncated && (
|
||||
<div className="px-2 py-1 text-[10px] text-warm border-t border-line/40">
|
||||
Preview truncated at {Math.round(MAX_FETCH_BYTES / 1024)} KB —{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(attachment)}
|
||||
className="underline"
|
||||
>
|
||||
download full file
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentVideo — inline native HTML5 <video controls> player for
|
||||
// chat attachments (RFC #2991, PR-2).
|
||||
//
|
||||
// Why HTML5-native (vs custom JS player):
|
||||
//
|
||||
// - Browser vendors ship hardware-accelerated decoders, captions,
|
||||
// and fullscreen UI. We get all of it for free.
|
||||
// - Native fullscreen via the <video> element's built-in button
|
||||
// (no AttachmentLightbox needed for video — the browser does it).
|
||||
// - Mobile-friendly: iOS / Android Safari + Chrome handle the
|
||||
// pinch + scrub UX the user already knows.
|
||||
//
|
||||
// Auth model — identical to AttachmentImage:
|
||||
// platform-auth URIs need our cookie/token, so we fetch the bytes,
|
||||
// wrap in a Blob, hand the browser an ObjectURL via <video src=>.
|
||||
// External (http/https) URIs skip the fetch and use the raw URL.
|
||||
//
|
||||
// Memory caveat: a Blob holds the entire video in JS memory until
|
||||
// the bubble unmounts. For multi-hundred-MB videos this is bad. The
|
||||
// server caps single-file uploads at 25MB (chat_files.go), so we're
|
||||
// bounded; if larger files become a real shape, switch to streaming
|
||||
// via MediaSource or just `<video src=…>` with a credentials-aware
|
||||
// fetch via service worker. v2 if measured-needed.
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ChatAttachment } from "./types";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||
import { AttachmentChip } from "./AttachmentViews";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
attachment: ChatAttachment;
|
||||
onDownload: (a: ChatAttachment) => void;
|
||||
tone: "user" | "agent";
|
||||
}
|
||||
|
||||
type FetchState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; src: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: Props) {
|
||||
const [state, setState] = useState<FetchState>({ kind: "idle" });
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
// External video (http/https) — let the browser stream it
|
||||
// natively without the JS-blob detour.
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
if (!cancelled) setState({ kind: "ready", src: href });
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
||||
const slug = getTenantSlug();
|
||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
||||
const res = await fetch(href, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
// Videos are larger than images on average; give the request
|
||||
// more headroom. The server's per-request body cap (50MB) is
|
||||
// still the actual ceiling.
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setState({ kind: "ready", src: url });
|
||||
} catch {
|
||||
if (!cancelled) setState({ kind: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [workspaceId, attachment.uri]);
|
||||
|
||||
if (state.kind === "error") {
|
||||
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
|
||||
}
|
||||
if (state.kind === "idle" || state.kind === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
|
||||
style={{ width: 320, height: 180 }}
|
||||
aria-label={`Loading ${attachment.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block rounded-lg overflow-hidden border ${
|
||||
tone === "user" ? "border-blue-400/30" : "border-line/50"
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
// preload="metadata" so the browser fetches just enough to
|
||||
// show duration + first frame thumbnail without streaming
|
||||
// the whole file before the user clicks play.
|
||||
preload="metadata"
|
||||
// playsInline keeps mobile Safari from auto-fullscreening
|
||||
// on play; the user can still hit the native fullscreen
|
||||
// button (or PiP on Chrome) if they want.
|
||||
playsInline
|
||||
// Native fullscreen via the <video> control bar; no
|
||||
// AttachmentLightbox needed for video.
|
||||
src={state.src}
|
||||
// Cap thumbnail / inline display so the bubble doesn't blow
|
||||
// up vertical layout for tall portrait clips. The native
|
||||
// fullscreen button uses the original aspect ratio.
|
||||
style={{ maxWidth: 320, maxHeight: 240, display: "block" }}
|
||||
// Bytes that aren't actually a valid video (corrupt blob,
|
||||
// wrong Content-Type) fail load → swap to chip.
|
||||
onError={() => setState({ kind: "error" })}
|
||||
>
|
||||
<track kind="captions" />
|
||||
{attachment.name}
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal helper — same shape as AttachmentImage's. Lifted to a
|
||||
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
|
||||
function getTenantSlug(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const host = window.location.hostname;
|
||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// AttachmentPreview component tests — pin the dispatch contract:
|
||||
// each kind goes to its dedicated renderer; kind=file falls back to
|
||||
// the chip; failure modes don't strand the user without a download.
|
||||
//
|
||||
// Per RFC #2991 Phase 4: every test must be able to fail. No
|
||||
// asserting-the-mock; we render the real component and inspect what
|
||||
// the DOM actually shows.
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock the auth-token env var so AttachmentImage's fetch doesn't
|
||||
// hit a real network. The fetch is itself mocked below.
|
||||
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
|
||||
|
||||
// Mock fetch so the AttachmentImage path can return a synthetic blob.
|
||||
// Tests override per-case to simulate success / 404 / network fail.
|
||||
const fetchMock = vi.fn();
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
// jsdom doesn't implement URL.createObjectURL — stub.
|
||||
global.URL.createObjectURL = vi.fn(() => "blob:test-url");
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
import { AttachmentPreview } from "../AttachmentPreview";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
const onDownload = vi.fn();
|
||||
|
||||
function preview(att: ChatAttachment) {
|
||||
return render(
|
||||
<AttachmentPreview
|
||||
workspaceId="ws-1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AttachmentPreview dispatch", () => {
|
||||
it("kind=file → renders the AttachmentChip download button (existing fallback)", () => {
|
||||
preview({ uri: "workspace:/workspace/tmp/foo.zip", name: "foo.zip", mimeType: "application/zip" });
|
||||
// The chip's button title is `Download <name>`. Pre-fix this was
|
||||
// the only render path; now it's the kind=file fallback.
|
||||
expect(screen.getByTitle(/Download foo\.zip/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=image (mime) → renders the AttachmentImage path (loading placeholder until fetch resolves)", async () => {
|
||||
// never-resolving fetch → component sits in loading state. Pin
|
||||
// the loading placeholder shape.
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
preview({ uri: "workspace:/workspace/tmp/photo.png", name: "photo.png", mimeType: "image/png" });
|
||||
expect(await screen.findByLabelText(/Loading photo\.png/i)).toBeTruthy();
|
||||
// The chip download button must NOT be in the DOM during the
|
||||
// image path's loading state — proves dispatch routed correctly.
|
||||
expect(screen.queryByTitle(/Download photo\.png/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("kind=image (extension fallback when mime is empty) → image path", async () => {
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
preview({ uri: "workspace:/workspace/screenshot.jpg", name: "screenshot.jpg" /* no mime */ });
|
||||
expect(await screen.findByLabelText(/Loading screenshot\.jpg/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=image fetch fails (404) → falls back to AttachmentChip so the user can still download", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/tmp/missing.png", name: "missing.png", mimeType: "image/png" });
|
||||
// The fallback chip shows up on error.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.png/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=image fetch network error → falls back to chip", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download x\.png/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=image success → renders <img> + clicking opens the lightbox", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-png-bytes"], { type: "image/png" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/tmp/ok.png", name: "ok.png", mimeType: "image/png" });
|
||||
|
||||
// Image element shows up after the fetch resolves.
|
||||
const img = await screen.findByAltText(/ok\.png/);
|
||||
expect(img).toBeTruthy();
|
||||
expect((img as HTMLImageElement).src).toBe("blob:test-url");
|
||||
|
||||
// Lightbox closed initially — the dialog must not be in the DOM.
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
// Click the thumbnail button (the surrounding <button>) → lightbox opens.
|
||||
const button = screen.getByLabelText(/Open ok\.png preview/i);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Close preview/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=image lightbox closes on Esc keypress", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["b"], { type: "image/png" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
|
||||
await screen.findByAltText(/x\.png/);
|
||||
fireEvent.click(screen.getByLabelText(/Open x\.png preview/i));
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
|
||||
// Esc on document — lightbox listens there per design (not on
|
||||
// the modal element) so the user can press Esc anywhere.
|
||||
act(() => {
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=image lightbox closes on backdrop click but not on inner content click", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["b"], { type: "image/png" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
|
||||
await screen.findByAltText(/x\.png/);
|
||||
fireEvent.click(screen.getByLabelText(/Open x\.png preview/i));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
// Click on the inner content (the lightbox image) — must NOT close.
|
||||
const lightboxImg = dialog.querySelector("img");
|
||||
if (!lightboxImg) throw new Error("lightbox img missing");
|
||||
fireEvent.click(lightboxImg);
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
|
||||
// Click on the backdrop (the dialog itself) — closes.
|
||||
fireEvent.click(dialog);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PR-2: video / audio dispatch ───────────────────────────────
|
||||
|
||||
it("kind=video → renders <video controls> after fetch resolves", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-mp4"], { type: "video/mp4" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/clip.mp4", name: "clip.mp4", mimeType: "video/mp4" });
|
||||
// Loading placeholder first.
|
||||
expect(await screen.findByLabelText(/Loading clip\.mp4/i)).toBeTruthy();
|
||||
// After the blob resolves, a <video> element with controls=true
|
||||
// is in the DOM. Use a tag query — there's no built-in role for
|
||||
// <video>, but the element is unambiguous in the bubble.
|
||||
await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v).not.toBeNull();
|
||||
// controls attribute pinned — without it the user can't play.
|
||||
expect(v?.hasAttribute("controls")).toBe(true);
|
||||
// src is the blob URL we minted.
|
||||
expect((v as HTMLVideoElement).src).toBe("blob:test-url");
|
||||
});
|
||||
// Chip MUST NOT render — proves dispatch routed to video, not file.
|
||||
expect(screen.queryByTitle(/Download clip\.mp4/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("kind=video fetch fails → falls back to AttachmentChip", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/missing.mp4", name: "missing.mp4", mimeType: "video/mp4" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.mp4/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=video by extension fallback (no mime) → video path", async () => {
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
preview({ uri: "workspace:/workspace/recording.webm", name: "recording.webm" });
|
||||
expect(await screen.findByLabelText(/Loading recording\.webm/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("kind=audio → renders <audio controls> with filename label", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-mp3"], { type: "audio/mpeg" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/song.mp3", name: "song.mp3", mimeType: "audio/mpeg" });
|
||||
await waitFor(() => {
|
||||
const a = document.querySelector("audio");
|
||||
expect(a).not.toBeNull();
|
||||
expect(a?.hasAttribute("controls")).toBe(true);
|
||||
expect((a as HTMLAudioElement).src).toBe("blob:test-url");
|
||||
});
|
||||
// Filename label pinned: helps the user know what they're hearing
|
||||
// BEFORE pressing play. Multiple matches — `<span>` text and the
|
||||
// `<audio>`'s fallback `{name}` text node — so getAllByText.
|
||||
expect(screen.getAllByText("song.mp3").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("kind=audio fetch fails → falls back to chip", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 403 });
|
||||
preview({ uri: "workspace:/workspace/locked.wav", name: "locked.wav", mimeType: "audio/wav" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download locked\.wav/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PR-3: PDF / text dispatch ─────────────────────────────────────
|
||||
|
||||
it("kind=pdf → renders the PDF preview chip (click opens lightbox)", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["%PDF-1.4..."], { type: "application/pdf" }),
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/doc.pdf", name: "doc.pdf", mimeType: "application/pdf" });
|
||||
|
||||
// Loading placeholder first.
|
||||
expect(await screen.findByLabelText(/Loading doc\.pdf/i)).toBeTruthy();
|
||||
|
||||
// After fetch, preview chip with "PDF" tag rendered.
|
||||
await waitFor(() => {
|
||||
// The button title is "Preview doc.pdf"; alongside is a "PDF" tag.
|
||||
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click → lightbox opens with <embed> inside.
|
||||
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("kind=pdf fetch fails → falls back to chip", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/missing.pdf", name: "missing.pdf", mimeType: "application/pdf" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.pdf/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=text (text/plain) → renders inline <pre><code> preview", async () => {
|
||||
const body = "line1\nline2\nline3";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
body: null,
|
||||
text: async () => body,
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/log.txt", name: "log.txt", mimeType: "text/plain" });
|
||||
|
||||
// testing-library normalizes whitespace by default. The <pre>
|
||||
// contains the literal text node, so query the DOM directly.
|
||||
await waitFor(() => {
|
||||
const code = document.querySelector("pre code");
|
||||
expect(code).not.toBeNull();
|
||||
expect(code?.textContent).toBe("line1\nline2\nline3");
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=text long content → shows 'Show all N lines' button when >10 lines", async () => {
|
||||
// 25 lines, default preview shows 10. Button labels with full count.
|
||||
const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
body: null,
|
||||
text: async () => body,
|
||||
});
|
||||
preview({ uri: "workspace:/workspace/big.txt", name: "big.txt", mimeType: "text/plain" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Show all 25 lines/i })).toBeTruthy();
|
||||
});
|
||||
// Pre-expand: only first 10 lines in <code>; line 11+ absent.
|
||||
let code = document.querySelector("pre code");
|
||||
expect(code?.textContent?.includes("line 10")).toBe(true);
|
||||
expect(code?.textContent?.includes("line 11")).toBe(false);
|
||||
|
||||
// After clicking expand, all 25 lines present.
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show all 25 lines/i }));
|
||||
await waitFor(() => {
|
||||
code = document.querySelector("pre code");
|
||||
expect(code?.textContent?.includes("line 25")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("kind=text fetch fails → chip fallback", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
preview({ uri: "workspace:/workspace/missing.json", name: "missing.json", mimeType: "application/json" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.json/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── universal-fallback regression ─────────────────────────────────
|
||||
|
||||
it("kind=file is the universal fallback for unknown MIME (regression: don't try to preview a zip)", () => {
|
||||
// Critical safety: agent could attach a misnamed file. Pre-fix
|
||||
// the chip path was unconditional; we want unknown MIME to
|
||||
// STILL go to the chip even though the extension matches an
|
||||
// image kind.
|
||||
preview({ uri: "workspace:/workspace/tmp/x.docx", name: "x.docx", mimeType: "application/vnd.zip-disguised-as-doc" });
|
||||
expect(screen.getByTitle(/Download x\.docx/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// preview-kind unit tests — exhaustive table of MIME / extension
|
||||
// combinations. The kind helper is a pure function; this is the
|
||||
// regression line for "what renders as what" across the entire chat
|
||||
// surface.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAttachmentPreviewKind } from "../preview-kind";
|
||||
|
||||
describe("getAttachmentPreviewKind", () => {
|
||||
describe("strict MIME match", () => {
|
||||
const cases: Array<[string, ReturnType<typeof getAttachmentPreviewKind>]> = [
|
||||
// images
|
||||
["image/png", "image"],
|
||||
["image/jpeg", "image"],
|
||||
["image/gif", "image"],
|
||||
["image/webp", "image"],
|
||||
["image/svg+xml", "image"],
|
||||
["image/avif", "image"],
|
||||
["IMAGE/PNG", "image"], // case-insensitive
|
||||
[" image/png ", "image"], // trim
|
||||
// video
|
||||
["video/mp4", "video"],
|
||||
["video/webm", "video"],
|
||||
["video/quicktime", "video"],
|
||||
// audio
|
||||
["audio/mpeg", "audio"],
|
||||
["audio/wav", "audio"],
|
||||
["audio/ogg", "audio"],
|
||||
// pdf
|
||||
["application/pdf", "pdf"],
|
||||
// text family
|
||||
["text/plain", "text"],
|
||||
["text/markdown", "text"],
|
||||
["text/html", "text"],
|
||||
["text/css", "text"],
|
||||
["text/javascript", "text"],
|
||||
["text/csv", "text"],
|
||||
["application/json", "text"],
|
||||
["application/yaml", "text"],
|
||||
["application/x-yaml", "text"],
|
||||
["application/javascript", "text"],
|
||||
["application/typescript", "text"],
|
||||
// unknown / non-renderable → file
|
||||
["application/zip", "file"],
|
||||
["application/octet-stream", "file"],
|
||||
["application/x-tar", "file"],
|
||||
["application/vnd.ms-excel", "file"],
|
||||
["weird/unknown-thing", "file"],
|
||||
];
|
||||
for (const [mime, expected] of cases) {
|
||||
it(`mimeType=${JSON.stringify(mime)} → ${expected}`, () => {
|
||||
expect(getAttachmentPreviewKind(mime)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("extension fallback when MIME is missing or generic", () => {
|
||||
const cases: Array<[string | undefined, string | undefined, string | undefined, ReturnType<typeof getAttachmentPreviewKind>]> = [
|
||||
// [mime, uri, name, expected]
|
||||
[undefined, "workspace:/tmp/screenshot.png", "screenshot.png", "image"],
|
||||
["", "workspace:/tmp/photo.JPG", "photo.JPG", "image"],
|
||||
["application/octet-stream", "workspace:/tmp/clip.mp4", "clip.mp4", "video"],
|
||||
[undefined, "workspace:/foo/song.mp3", "song.mp3", "audio"],
|
||||
[undefined, "workspace:/docs/report.pdf", "report.pdf", "pdf"],
|
||||
[undefined, "workspace:/code/main.py", "main.py", "text"],
|
||||
[undefined, "workspace:/data/notes.md", "notes.md", "text"],
|
||||
// No extension → file
|
||||
[undefined, "workspace:/tmp/Dockerfile", "Dockerfile", "file"],
|
||||
// Trailing dot → file
|
||||
[undefined, "workspace:/tmp/weird.", "weird.", "file"],
|
||||
// URL with query string + fragment → strip before parsing
|
||||
[undefined, "https://example.com/foo.png?download=1#anchor", "", "image"],
|
||||
// Unknown extension → file
|
||||
[undefined, "workspace:/tmp/something.xyz", "something.xyz", "file"],
|
||||
// Empty
|
||||
[undefined, "", "", "file"],
|
||||
[undefined, undefined, undefined, "file"],
|
||||
];
|
||||
for (const [mime, uri, name, expected] of cases) {
|
||||
it(`mime=${mime ?? "<undef>"} uri=${uri} name=${name} → ${expected}`, () => {
|
||||
expect(getAttachmentPreviewKind(mime, uri, name)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("MIME wins over extension", () => {
|
||||
it("explicit mime=application/zip + extension=.png → file (don't render zip as image)", () => {
|
||||
// Critical safety: agent might attach a .png-named file that's
|
||||
// actually a zip. The strict-MIME branch wins and we render
|
||||
// the chip, not an <img> that 404s on broken bytes.
|
||||
expect(getAttachmentPreviewKind("application/zip", "x.png", "x.png")).toBe("file");
|
||||
});
|
||||
|
||||
it("explicit mime=text/plain + extension=.png → text", () => {
|
||||
expect(getAttachmentPreviewKind("text/plain", "log.png", "log.png")).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: hostile-reviewer cases", () => {
|
||||
it("does NOT misclassify image/svg+xml as text (svg is image even though it has XML)", () => {
|
||||
expect(getAttachmentPreviewKind("image/svg+xml")).toBe("image");
|
||||
});
|
||||
|
||||
it("application/octet-stream + extension=.docx → file (no renderer, don't try)", () => {
|
||||
expect(getAttachmentPreviewKind("application/octet-stream", "f.docx", "f.docx")).toBe("file");
|
||||
});
|
||||
|
||||
it("non-canonical MIME application/json works", () => {
|
||||
expect(getAttachmentPreviewKind("application/json")).toBe("text");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveAttachmentHref } from "../uploads";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "../uploads";
|
||||
|
||||
describe("resolveAttachmentHref — URI scheme normalisation", () => {
|
||||
const wsId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
@@ -39,3 +39,128 @@ describe("resolveAttachmentHref — URI scheme normalisation", () => {
|
||||
expect(resolveAttachmentHref(wsId, "s3://bucket/key")).toBe("s3://bucket/key");
|
||||
});
|
||||
});
|
||||
|
||||
// #2973 follow-up to #2968: cover the platform-pending: scheme branch
|
||||
// (poll-mode chat uploads) + the isPlatformAttachment SSOT helper that
|
||||
// the chip-download and markdown-link paths both consume.
|
||||
//
|
||||
// Pre-fix the platform-pending: URI fell through to the raw URI →
|
||||
// browser saw an unhandled-protocol click → about:blank. The fix
|
||||
// resolves it to the platform pending-uploads endpoint with auth
|
||||
// headers attached.
|
||||
describe("resolveAttachmentHref — platform-pending: scheme (poll-mode uploads)", () => {
|
||||
// Use a chat workspace ID that DIFFERS from the one in the URI, so
|
||||
// tests can verify which one the resolver uses. The forward-across-
|
||||
// workspace case is real production behavior — files dragged into one
|
||||
// workspace's chat can be referenced from another.
|
||||
const chatWs = "chat-ws-aaaaaaaa";
|
||||
const sourceWs = "source-ws-bbbbbbbb";
|
||||
|
||||
it("resolves a well-formed platform-pending: URI to /pending-uploads/<file>/content", () => {
|
||||
const url = resolveAttachmentHref(
|
||||
chatWs,
|
||||
`platform-pending:${sourceWs}/file-12345`,
|
||||
);
|
||||
expect(url).toContain(`/workspaces/${sourceWs}/pending-uploads/file-12345/content`);
|
||||
});
|
||||
|
||||
it("uses the URI's wsid, NOT the chat workspace_id (cross-workspace forwarding)", () => {
|
||||
// The two ids differ — this is the case PR #2968's commit
|
||||
// explicitly calls out. A regression that flipped this would
|
||||
// silently mis-route the download to the WRONG workspace's
|
||||
// pending-uploads store, returning 404 (or worse, leaking).
|
||||
const url = resolveAttachmentHref(
|
||||
chatWs,
|
||||
`platform-pending:${sourceWs}/file-xyz`,
|
||||
);
|
||||
expect(url).toContain(`/workspaces/${sourceWs}/`);
|
||||
expect(url).not.toContain(`/workspaces/${chatWs}/`);
|
||||
});
|
||||
|
||||
it("falls back to raw URI when platform-pending: is missing the slash", () => {
|
||||
// Defensive: a URI that drifted from the expected wsid/fileid shape
|
||||
// returns raw rather than producing a broken /pending-uploads//
|
||||
// path. Pinned to detect a regression where a future "helpful"
|
||||
// change synthesizes empty wsid/fileID.
|
||||
expect(resolveAttachmentHref(chatWs, "platform-pending:no-slash")).toBe(
|
||||
"platform-pending:no-slash",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to raw URI when platform-pending: has empty fileID", () => {
|
||||
expect(resolveAttachmentHref(chatWs, "platform-pending:abc/")).toBe(
|
||||
"platform-pending:abc/",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to raw URI when platform-pending: has empty wsid", () => {
|
||||
expect(resolveAttachmentHref(chatWs, "platform-pending:/file-xyz")).toBe(
|
||||
"platform-pending:/file-xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("regression: exact production repro from #2968 (reno-stars)", () => {
|
||||
// From the original PR #2968 body: the chat's markdown-link
|
||||
// override fell through on this exact shape and the browser
|
||||
// navigated to about:blank. Pin the post-fix output so a future
|
||||
// refactor can't reintroduce the original bug.
|
||||
const url = resolveAttachmentHref(
|
||||
"chat-ws",
|
||||
"platform-pending:d76977b1-uuid/bb0dcaf3-uuid",
|
||||
);
|
||||
expect(url).toContain("/workspaces/d76977b1-uuid/pending-uploads/bb0dcaf3-uuid/content");
|
||||
expect(url).not.toContain("chat-ws");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPlatformAttachment", () => {
|
||||
it("returns true for platform-pending: URIs", () => {
|
||||
expect(isPlatformAttachment("platform-pending:abc/file")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true even for malformed platform-pending: URIs", () => {
|
||||
// The helper is a SHAPE check — caller routes through
|
||||
// downloadChatFile and downloadChatFile handles the malformed case
|
||||
// downstream. Pinning so a future helper that "validates" the
|
||||
// wsid/fileID shape doesn't silently break the auth-attached
|
||||
// download flow for in-flight URIs.
|
||||
expect(isPlatformAttachment("platform-pending:no-slash")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for workspace:<allowed-root> URIs", () => {
|
||||
expect(isPlatformAttachment("workspace:/configs/foo")).toBe(true);
|
||||
expect(isPlatformAttachment("workspace:/workspace/x.pdf")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for file:///<allowed-root> URIs", () => {
|
||||
expect(isPlatformAttachment("file:///workspace/x")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for absolute paths under allowed roots", () => {
|
||||
expect(isPlatformAttachment("/home/user/x")).toBe(true);
|
||||
expect(isPlatformAttachment("/configs/y")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns FALSE for bare HTTPS URLs to other origins", () => {
|
||||
// Auth-leak class regression: a helper that always returned true
|
||||
// would attach workspace tokens to third-party requests. Pin
|
||||
// the negative case explicitly.
|
||||
expect(isPlatformAttachment("https://example.com/file")).toBe(false);
|
||||
expect(isPlatformAttachment("http://example.com/file")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns FALSE for non-allowlisted root paths", () => {
|
||||
expect(isPlatformAttachment("/etc/passwd")).toBe(false);
|
||||
expect(isPlatformAttachment("/var/log/x")).toBe(false);
|
||||
expect(isPlatformAttachment("/tmp/x")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns FALSE for empty string", () => {
|
||||
expect(isPlatformAttachment("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns FALSE for unrecognised schemes", () => {
|
||||
expect(isPlatformAttachment("s3://bucket/key")).toBe(false);
|
||||
expect(isPlatformAttachment("ftp://server/file")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// preview-kind.ts — single source of truth for "what renderer should
|
||||
// this attachment use" (RFC #2991, PR-1).
|
||||
//
|
||||
// Per the RFC's Phase 2 design, MIME type is the dispatch axis. The
|
||||
// wire shape (ChatAttachment.mimeType) already carries it end-to-end
|
||||
// from the server's chat_files.go through agent_message_writer.go to
|
||||
// the canvas hydrater — we just need to map it to a render kind.
|
||||
//
|
||||
// Why a separate file from AttachmentPreview.tsx: the kind helper is
|
||||
// a pure function that's easier to unit-test in isolation than a
|
||||
// React component, and unit tests across MIME families are the
|
||||
// regression line for new types added later.
|
||||
|
||||
/** The render-kind taxonomy. Each kind has a dedicated component:
|
||||
*
|
||||
* image → AttachmentImage (inline thumbnail + click → lightbox)
|
||||
* video → AttachmentVideo (HTML5 <video controls>, native fullscreen)
|
||||
* audio → AttachmentAudio (HTML5 <audio controls>)
|
||||
* pdf → AttachmentPDF (browser-native <embed>, fullscreen modal)
|
||||
* text → AttachmentTextPreview (monospace, first N lines, expand)
|
||||
* file → AttachmentChip (existing fallback — generic file pill)
|
||||
*
|
||||
* NB: `text` includes JSON, YAML, source code, plain text — anything
|
||||
* that renders sensibly as preformatted ASCII without a specialized
|
||||
* viewer. PR-1 ships only `image` + `file`; PR-2 adds video/audio;
|
||||
* PR-3 adds pdf + text. All routed through this same dispatch table
|
||||
* so adding a new kind is a one-line registration. */
|
||||
export type AttachmentPreviewKind = "image" | "video" | "audio" | "pdf" | "text" | "file";
|
||||
|
||||
/** Maps a MIME type to the render kind. Falls back to "file" for
|
||||
* any MIME we don't have a renderer for (current behavior — the
|
||||
* attachment chip is the universal fallback).
|
||||
*
|
||||
* Filename-based fallback: when mimeType is missing or generic
|
||||
* (application/octet-stream), inspect the URI's extension. The
|
||||
* workspace-server's chat_files.go derives Content-Type from the
|
||||
* file extension, but agent-emitted attachments may not always
|
||||
* set mimeType, and the canvas should still preview a file named
|
||||
* `screenshot.png` even if the wire shape lacks the MIME.
|
||||
*
|
||||
* Strict MIME match always wins; extension fallback only applies
|
||||
* to empty / generic. Unknown extension → "file". */
|
||||
export function getAttachmentPreviewKind(
|
||||
mimeType: string | undefined,
|
||||
uri?: string,
|
||||
name?: string,
|
||||
): AttachmentPreviewKind {
|
||||
const mime = (mimeType ?? "").toLowerCase().trim();
|
||||
|
||||
// Strict MIME match (preferred — set by server's Content-Type
|
||||
// detection or by the agent's explicit mimeType field).
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (
|
||||
mime.startsWith("text/") ||
|
||||
mime === "application/json" ||
|
||||
mime === "application/yaml" ||
|
||||
mime === "application/x-yaml" ||
|
||||
mime === "application/javascript" ||
|
||||
mime === "application/typescript"
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
// Extension-based fallback — only when MIME is missing or
|
||||
// application/octet-stream (the server's "I don't know" default).
|
||||
// Skip when MIME is set to something specific we just don't have
|
||||
// a renderer for (e.g. application/zip → file is correct).
|
||||
const looksGeneric = mime === "" || mime === "application/octet-stream";
|
||||
if (looksGeneric) {
|
||||
const ext = extractExtension(uri, name);
|
||||
if (ext) {
|
||||
const kind = EXTENSION_KIND.get(ext);
|
||||
if (kind) return kind;
|
||||
}
|
||||
}
|
||||
|
||||
return "file";
|
||||
}
|
||||
|
||||
// Extension → kind table for the fallback branch. Keep this list
|
||||
// short and curated — every entry is a UX commitment to render
|
||||
// inline, and a wrong inference (e.g. .doc rendered as text) is
|
||||
// worse than the generic file chip.
|
||||
const EXTENSION_KIND: ReadonlyMap<string, AttachmentPreviewKind> = new Map([
|
||||
// Images
|
||||
["png", "image"],
|
||||
["jpg", "image"],
|
||||
["jpeg", "image"],
|
||||
["gif", "image"],
|
||||
["webp", "image"],
|
||||
["svg", "image"],
|
||||
["avif", "image"],
|
||||
["bmp", "image"],
|
||||
// Video
|
||||
["mp4", "video"],
|
||||
["webm", "video"],
|
||||
["mov", "video"],
|
||||
["mkv", "video"],
|
||||
// Audio
|
||||
["mp3", "audio"],
|
||||
["wav", "audio"],
|
||||
["ogg", "audio"],
|
||||
["m4a", "audio"],
|
||||
["flac", "audio"],
|
||||
// PDF
|
||||
["pdf", "pdf"],
|
||||
// Text-ish (rendered as preformatted ASCII)
|
||||
["txt", "text"],
|
||||
["md", "text"],
|
||||
["json", "text"],
|
||||
["yaml", "text"],
|
||||
["yml", "text"],
|
||||
["js", "text"],
|
||||
["ts", "text"],
|
||||
["tsx", "text"],
|
||||
["jsx", "text"],
|
||||
["py", "text"],
|
||||
["go", "text"],
|
||||
["rs", "text"],
|
||||
["java", "text"],
|
||||
["c", "text"],
|
||||
["cpp", "text"],
|
||||
["h", "text"],
|
||||
["hpp", "text"],
|
||||
["sh", "text"],
|
||||
["bash", "text"],
|
||||
["html", "text"],
|
||||
["css", "text"],
|
||||
["sql", "text"],
|
||||
["toml", "text"],
|
||||
["ini", "text"],
|
||||
["xml", "text"],
|
||||
["csv", "text"],
|
||||
["log", "text"],
|
||||
]);
|
||||
|
||||
/** Extracts the lowercased extension from a uri or name, without
|
||||
* the leading dot. Returns "" when no extension is present. */
|
||||
function extractExtension(uri: string | undefined, name: string | undefined): string {
|
||||
// Prefer name (always a leaf path); fall back to uri's last
|
||||
// segment. Strip query string + fragment so a URI like
|
||||
// "https://example.com/foo.png?download=1" still parses as png.
|
||||
const candidate = name || uri || "";
|
||||
if (!candidate) return "";
|
||||
let leaf = candidate.split(/[\\/]/).pop() || "";
|
||||
// Drop ?query and #fragment.
|
||||
leaf = leaf.split(/[?#]/)[0];
|
||||
const dot = leaf.lastIndexOf(".");
|
||||
if (dot < 0 || dot === leaf.length - 1) return "";
|
||||
return leaf.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export async function uploadChatFiles(
|
||||
* - `workspace:<abs-path>` (our canonical form)
|
||||
* - `file:///workspace/...` (some agents emit this)
|
||||
* - `/workspace/...` (bare absolute path inside the container)
|
||||
* - `platform-pending:<wsid>/<file_id>` (poll-mode upload, staged
|
||||
* on platform side; resolves to /pending-uploads/<file_id>/content)
|
||||
* Everything that looks like an allowed-root container path is
|
||||
* rewritten to the authenticated /chat/download endpoint. HTTP(S)
|
||||
* URIs pass through unchanged so we can also render links to
|
||||
@@ -53,6 +55,35 @@ export function resolveAttachmentHref(
|
||||
workspaceId: string,
|
||||
uri: string,
|
||||
): string {
|
||||
// platform-pending: agents-emitted URI that lives in the platform-side
|
||||
// staging layer (poll-mode chat uploads, see workspace-server's
|
||||
// chat_files.go ~line 690 + pendinguploads.Storage). The wire shape
|
||||
// is `platform-pending:<workspace_id>/<file_id>`. Resolving it
|
||||
// requires hitting GET /workspaces/<wsid>/pending-uploads/<file_id>/content
|
||||
// which streams the bytes with full workspace auth. Without this
|
||||
// case the browser sees an unhandled-protocol click → about:blank,
|
||||
// which was the user-visible bug from 2026-05-05 (reno-stars).
|
||||
if (uri.startsWith("platform-pending:")) {
|
||||
const rest = uri.slice("platform-pending:".length);
|
||||
const slash = rest.indexOf("/");
|
||||
// Defensive: if the URI doesn't have the expected wsid/fileid
|
||||
// shape, fall through to raw-URI handling so the consumer can
|
||||
// still try to render it (rather than producing a broken /pending-
|
||||
// uploads/// path).
|
||||
if (slash > 0) {
|
||||
const wsid = rest.slice(0, slash);
|
||||
const fileID = rest.slice(slash + 1);
|
||||
if (wsid && fileID) {
|
||||
// Use the URI's own workspace_id (the bytes live in THAT
|
||||
// workspace's pending-uploads store), not the chat's
|
||||
// workspace_id — these CAN differ when a user drags a file
|
||||
// into one workspace's chat that gets forwarded to another
|
||||
// (cross-workspace delegation, agent forwarding).
|
||||
return `${PLATFORM_URL}/workspaces/${wsid}/pending-uploads/${fileID}/content`;
|
||||
}
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
const containerPath = normalizeWorkspaceUri(uri);
|
||||
if (containerPath) {
|
||||
return `${PLATFORM_URL}/workspaces/${workspaceId}/chat/download?path=${encodeURIComponent(containerPath)}`;
|
||||
@@ -60,6 +91,14 @@ export function resolveAttachmentHref(
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Returns true when the URI points at a platform-side resource that
|
||||
* requires our auth headers — caller should route through
|
||||
* downloadChatFile rather than letting the browser navigate. */
|
||||
export function isPlatformAttachment(uri: string): boolean {
|
||||
if (uri.startsWith("platform-pending:")) return true;
|
||||
return normalizeWorkspaceUri(uri) !== null;
|
||||
}
|
||||
|
||||
/** Extracts the absolute container path from a workspace-scoped URI,
|
||||
* or null if the URI isn't a container path. The matching roots
|
||||
* mirror the server's `allowedRoots` allowlist. */
|
||||
@@ -96,8 +135,7 @@ export async function downloadChatFile(
|
||||
attachment: ChatAttachment,
|
||||
): Promise<void> {
|
||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||
const isContainerPath = normalizeWorkspaceUri(attachment.uri) !== null;
|
||||
if (!isContainerPath) {
|
||||
if (!isPlatformAttachment(attachment.uri)) {
|
||||
// External URL — let the browser navigate. Opens in new tab so
|
||||
// the canvas context survives a navigation. `href` here is the
|
||||
// raw URI (http(s), or anything else the agent sent back).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-002: Local-build mode signalled by `MOLECULE_IMAGE_REGISTRY` presence
|
||||
|
||||
* Status: Accepted (2026-05-07)
|
||||
* Issue: #63 (closes Task #194)
|
||||
* Decision: Hongming (CTO) + Claude Opus 4.7 (implementation)
|
||||
|
||||
## Context
|
||||
|
||||
Pre-2026-05-06, every Molecule deployment — both production tenants and OSS contributor laptops — pulled workspace-template-* container images from `ghcr.io/molecule-ai/`. Production tenants additionally set `MOLECULE_IMAGE_REGISTRY` to an AWS ECR mirror via Railway env / EC2 user-data, but the OSS default was the upstream GHCR org.
|
||||
|
||||
On 2026-05-06 the `Molecule-AI` GitHub org was suspended (saved memory: `feedback_github_botring_fingerprint`). GHCR now returns **403 Forbidden** for every `molecule-ai/workspace-template-*` manifest. OSS contributors who clone `molecule-core` and run `go run ./workspace-server/cmd/server` cannot provision a workspace — every first provision fails with:
|
||||
|
||||
```
|
||||
docker image "ghcr.io/molecule-ai/workspace-template-claude-code:latest" not found after pull attempt
|
||||
```
|
||||
|
||||
Production tenants are unaffected (their `MOLECULE_IMAGE_REGISTRY` points at ECR, which we still control), but OSS onboarding is broken. Workspace template repos are intentionally separate from `molecule-core` (each runtime is OSS-shape and forkable), and they are mirrored to Gitea (`https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-<runtime>`) — but the provisioner has no path that consumes Gitea source directly.
|
||||
|
||||
## Decision
|
||||
|
||||
When `MOLECULE_IMAGE_REGISTRY` is **unset** (or empty), the provisioner switches to a **local-build mode** that:
|
||||
|
||||
1. Looks up the workspace-template repo's HEAD sha on Gitea via a single API call.
|
||||
2. Checks whether a SHA-pinned local image (`molecule-local/workspace-template-<runtime>:<sha12>`) already exists; if so, reuses it.
|
||||
3. Otherwise shallow-clones the repo into `~/.cache/molecule/workspace-template-build/<runtime>/<sha12>/` and runs `docker build --platform=linux/amd64 -t <tag> .`.
|
||||
4. Hands the SHA-pinned tag to Docker for ContainerCreate, bypassing the registry-pull path entirely.
|
||||
|
||||
When `MOLECULE_IMAGE_REGISTRY` is **set**, behavior is unchanged: pull the image from that registry. Existing prod tenants and self-hosters who mirror to a private registry are not affected.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
* **Zero-config OSS onboarding** — `git clone molecule-core && go run ./workspace-server/cmd/server` boots end-to-end without any registry credentials.
|
||||
* **Production tenants protected** — same env var, same semantics in SaaS-mode. Migration is a no-op.
|
||||
* **No new env var** — extending an existing var's semantics ("where to pull, OR build locally if absent") rather than introducing `MOLECULE_LOCAL_BUILD=1` keeps the surface small.
|
||||
* **SHA-pinned cache** — repeat builds are O(API-call); only template-repo HEAD changes invalidate.
|
||||
* **Production-parity image** — amd64 emulation on Apple Silicon honours `feedback_local_must_mimic_production`. The provisioner's existing `defaultImagePlatform()` already forces amd64 for parity; building amd64 locally lets that decision stay consistent.
|
||||
|
||||
### Negative
|
||||
|
||||
* **Conflates two concerns** — `MOLECULE_IMAGE_REGISTRY` now signals BOTH "where to pull" AND "build locally if absent." A future operator who unsets it expecting a hard error will instead get a slow first-provision. Documented in the runbook.
|
||||
* **First-provision is slow on Apple Silicon** — 5–10 min via QEMU emulation on the cold path. Mitigated by SHA-cache (subsequent runs are <1s lookup + 0s build).
|
||||
* **Coverage gap** — only 4 of 9 runtimes are mirrored to Gitea today (`claude-code`, `hermes`, `langgraph`, `autogen`). The other 5 fail with an actionable "not mirrored" error. Mirroring those repos is a separate task.
|
||||
* **Implicit trust boundary** — operator running `go run` implicitly trusts `molecule-ai/molecule-ai-workspace-template-*` repos on Gitea. This is the same trust they would extend to the GHCR images today; not a new attack surface.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **New env var `MOLECULE_LOCAL_BUILD=1`** — explicit, but requires OSS contributors to know it exists. Violates the zero-config goal.
|
||||
2. **Push pre-built images to a Gitea container registry, mirror tag from upstream** — operationally cleaner but: (a) Gitea's container-registry add-on isn't deployed on the operator host, (b) defeats the OSS-contributor goal of "hack on the source, see your changes," since they'd still pull a stale image.
|
||||
3. **Embed Dockerfiles in molecule-core itself, drop the standalone template repos** — would work but breaks the OSS-shape principle; templates are intentionally separable, anyone-can-fork artifacts.
|
||||
4. **Build native arch on Apple Silicon (arm64) and drop the platform pin in local-mode** — fast, but creates `linux/arm64` images that diverge from the amd64-only prod runtime. Local-vs-prod debug behavior would diverge. Rejected per `feedback_local_must_mimic_production`.
|
||||
|
||||
## Security review
|
||||
|
||||
* **Gitea repo URL allowlist** — runtime name must be in the `knownRuntimes` allowlist (defence-in-depth against a future code path that lets cfg.Runtime carry untrusted input). Repo prefix is hardcoded to `https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-`; forks can override via `MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX` (opt-in, default off).
|
||||
* **Token handling** — clones are anonymous over HTTPS by default (templates are public). `MOLECULE_GITEA_TOKEN`, if set, is passed via URL userinfo for the clone and as `Authorization: token` for the API call. The token is **masked in every log line** via `maskTokenInURL` / `maskTokenInString` and never appears in the cache dir path.
|
||||
* **No silent fallback** — if Gitea is unreachable or the runtime isn't mirrored, we return a clear error mentioning the repo URL and the missing runtime. We **never** fall back to GHCR/ECR (that would be a confusing bug for an OSS contributor who happened to have stale ECR creds in their docker config).
|
||||
* **Build-arg injection** — `docker build` is invoked with NO `--build-arg` from external input. Dockerfile is consumed as-is.
|
||||
* **Cache poisoning** — cache key is the Gitea HEAD sha + Dockerfile content; a force-push to the template repo's main branch regenerates the key on next run. Cache dir is per-user (`$HOME/.cache`), so cross-user attacks aren't relevant in single-user dev mode.
|
||||
|
||||
## Versioning + back-compat
|
||||
|
||||
* Existing prod tenants set `MOLECULE_IMAGE_REGISTRY=<ECR url>` → unchanged behavior.
|
||||
* Existing local installs that set the var → unchanged behavior.
|
||||
* Existing local installs that don't set it → switch to local-build path. Migration: none required (additive); first provision will take 5–10 min instead of failing.
|
||||
* No deprecations.
|
||||
|
||||
## References
|
||||
|
||||
* Issue #63 — feat(workspace-server): local-dev provisioner builds from Gitea source
|
||||
* Saved memory `feedback_local_must_mimic_production` — local docker must mimic prod, no bypasses
|
||||
* Saved memory `reference_post_suspension_pipeline` — full post-2026-05-06 stack shape
|
||||
* Saved memory `feedback_github_botring_fingerprint` — what got the org suspended
|
||||
@@ -1,5 +1,41 @@
|
||||
# Local Development
|
||||
|
||||
## Workspace Template Images: Local-Build Mode (Issue #63)
|
||||
|
||||
OSS contributors who run `molecule-core` locally do **not** need to authenticate to GHCR or AWS ECR. When the `MOLECULE_IMAGE_REGISTRY` env var is **unset**, the platform automatically:
|
||||
|
||||
1. Looks up the HEAD sha of `https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-<runtime>` (single API call, no clone).
|
||||
2. If a local image tagged `molecule-local/workspace-template-<runtime>:<sha12>` already exists, reuses it (cache hit).
|
||||
3. Otherwise, shallow-clones the repo into `~/.cache/molecule/workspace-template-build/<runtime>/<sha12>/` and runs `docker build --platform=linux/amd64 -t <tag> .`.
|
||||
4. Hands the SHA-pinned tag to Docker for `ContainerCreate`.
|
||||
|
||||
**First-provision build time:** 5–10 min on Apple Silicon (amd64 emulation). Subsequent provisions hit the cache and start in seconds. Cache is invalidated automatically when the template repo's HEAD moves.
|
||||
|
||||
**Currently mirrored on Gitea:** `claude-code`, `hermes`, `langgraph`, `autogen`. Other runtimes (`crewai`, `deepagents`, `codex`, `gemini-cli`, `openclaw`) fail with an actionable "not mirrored to Gitea" error pointing at the missing repo.
|
||||
|
||||
**Production tenants are unaffected** — every prod tenant sets `MOLECULE_IMAGE_REGISTRY` to its private ECR mirror via Railway env / EC2 user-data, so the SaaS pull path stays identical.
|
||||
|
||||
### Environment overrides
|
||||
|
||||
| Var | Default | Use case |
|
||||
|-----|---------|----------|
|
||||
| `MOLECULE_IMAGE_REGISTRY` | (unset) | Set to a real registry URL to switch from local-build to SaaS-pull mode. |
|
||||
| `MOLECULE_LOCAL_BUILD_CACHE` | `~/.cache/molecule/workspace-template-build` | Override cache directory. |
|
||||
| `MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX` | `https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-` | Point at a fork. |
|
||||
| `MOLECULE_GITEA_TOKEN` | (unset) | Required only if your fork has private template repos. |
|
||||
|
||||
### Verifying a switch from the GHCR-retag stopgap
|
||||
|
||||
Pre-fix, OSS contributors worked around the suspended GHCR org by manually retagging an `:latest` image. After this change, that workaround is **redundant**: simply unset `MOLECULE_IMAGE_REGISTRY` (or leave it unset), boot the platform, and provision a workspace. Logs will show:
|
||||
|
||||
```
|
||||
Provisioner: local-build mode → using locally-built image molecule-local/workspace-template-claude-code:<sha12> for runtime claude-code
|
||||
local-build: cloning https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-claude-code → ...
|
||||
local-build: docker build done in <duration>
|
||||
```
|
||||
|
||||
If you still see `ghcr.io/molecule-ai/...` in the boot log, double-check `env | grep MOLECULE_IMAGE_REGISTRY` — a stale shell export from the pre-fix workaround could keep SaaS-mode active.
|
||||
|
||||
## Starting the Stack
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Rate-limit observability runbook
|
||||
|
||||
> Companion to issue #64 ("RATE_LIMIT default re-tune analysis"). After
|
||||
> #60 deployed the per-tenant `keyFor` keying, the right RATE_LIMIT
|
||||
> default became data-dependent. This runbook documents the metrics +
|
||||
> queries an operator should run to confirm whether the current 600
|
||||
> req/min/key default is correct, too tight, or too loose.
|
||||
|
||||
## What's already exposed
|
||||
|
||||
The workspace-server's existing Prometheus middleware
|
||||
(`workspace-server/internal/metrics/metrics.go`) tracks every request
|
||||
on every path:
|
||||
|
||||
```
|
||||
molecule_http_requests_total{method, path, status} counter
|
||||
molecule_http_request_duration_seconds_total{method,path,status} counter
|
||||
```
|
||||
|
||||
Path is the matched route pattern (`/workspaces/:id/activity` etc), so
|
||||
high-cardinality workspace UUIDs do not explode the label space.
|
||||
|
||||
The rate limiter middleware (#60, `workspace-server/internal/middleware/ratelimit.go`)
|
||||
also stamps every response with `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
|
||||
and `X-RateLimit-Reset`. Operators with browser-side or proxy-side
|
||||
header capture can read per-request bucket state directly.
|
||||
|
||||
No new instrumentation is needed for #64's acceptance criteria. The
|
||||
metric surface is sufficient — this runbook just collects the queries.
|
||||
|
||||
## Queries to run after #60 deploys
|
||||
|
||||
### 1. Is the bucket actually firing 429s?
|
||||
|
||||
```promql
|
||||
sum(rate(molecule_http_requests_total{status="429"}[5m]))
|
||||
```
|
||||
|
||||
If this is zero on a given tenant, the bucket isn't being hit. If it's
|
||||
sustained > 1/min, dig in.
|
||||
|
||||
### 2. Which routes attract 429s?
|
||||
|
||||
```promql
|
||||
topk(
|
||||
10,
|
||||
sum by (path) (
|
||||
rate(molecule_http_requests_total{status="429"}[5m])
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Expected shape post-#60:
|
||||
- `/workspaces/:id/activity` should be near zero — the canvas no longer
|
||||
polls it on a 30s/60s/5s cadence (PRs #69 / #71 / #76).
|
||||
- Probe / health / heartbeat paths should be ~0 (those routes have a
|
||||
separate IP-fallback bucket).
|
||||
|
||||
If `/workspaces/:id/activity` 429s persist post-PRs-69/71/76 deploy, the
|
||||
canvas isn't running the WS-subscriber path — investigate WS health
|
||||
on that tenant.
|
||||
|
||||
### 3. Per-bucket-key inference (no direct exposure today)
|
||||
|
||||
The bucket map itself is in-memory only; we deliberately do **not**
|
||||
expose `org:<uuid>` ↔ remaining-tokens because that map can include
|
||||
SHA-256 hashes of bearer tokens. A tenant that wants per-key visibility
|
||||
should rely on response headers (`X-RateLimit-Remaining` on every
|
||||
response from a given session is the bucket's view of that session).
|
||||
|
||||
If you genuinely need server-side per-bucket counts for triage,
|
||||
file a follow-up — the proper shape is a `/internal/ratelimit-stats`
|
||||
endpoint that emits **counts per key prefix only** (e.g. `org:`, `tok:`,
|
||||
`ip:`), never the key payloads. Don't roll that ad-hoc; it's a security
|
||||
review surface.
|
||||
|
||||
## Decision tree for the re-tune
|
||||
|
||||
After 14 days of production traffic on a tenant, look at the queries
|
||||
above and walk this tree:
|
||||
|
||||
```
|
||||
Q1: Is the 429 rate sustained > 0.1/sec on any tenant?
|
||||
├─ NO → The 600 default has comfortable headroom. Either keep it,
|
||||
│ or lower it carefully (300) ONLY if you have a documented
|
||||
│ reason (e.g. a misbehaving client we want to throttle harder).
|
||||
│ Default to "no change" — see #64 for the math.
|
||||
└─ YES → Q2.
|
||||
|
||||
Q2: Is the 429 rate concentrated on ONE tenant or spread across many?
|
||||
├─ ONE tenant → Operator override: set RATE_LIMIT=1200 or 1800 on that
|
||||
│ tenant's box. Document in the tenant's ops note. The
|
||||
│ default does not need to change.
|
||||
└─ MANY tenants → Q3.
|
||||
|
||||
Q3: Are the 429s on a route that polls (e.g. /activity / /peers)?
|
||||
├─ YES → Confirm PRs #69, #71, #76 have actually deployed to those
|
||||
│ tenants. If they have and 429s persist, the canvas may have
|
||||
│ a regression — do not raise RATE_LIMIT. File a canvas issue.
|
||||
└─ NO → 429s on mutating routes mean genuine load. Raise the default
|
||||
to 1200 in `workspace-server/internal/router/router.go:54`.
|
||||
Same PR should attach: the metric chart, the time window,
|
||||
and a paragraph explaining what changed in our traffic shape.
|
||||
```
|
||||
|
||||
## Alert rule template (drop-in for Prometheus)
|
||||
|
||||
```yaml
|
||||
# Sustained 429s — file is the SLO trip-wire. If this fires, walk the
|
||||
# decision tree above. NB: the issue#64 acceptance criterion is "two
|
||||
# weeks of metrics"; this alert is the inverse — it tells you something
|
||||
# changed before the two weeks are up.
|
||||
groups:
|
||||
- name: workspace-server-ratelimit
|
||||
rules:
|
||||
- alert: WorkspaceServerRateLimit429Sustained
|
||||
expr: |
|
||||
sum by (instance) (
|
||||
rate(molecule_http_requests_total{status="429"}[10m])
|
||||
) > 0.1
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
owner: workspace-server
|
||||
annotations:
|
||||
summary: "{{ $labels.instance }} sustained 429s — see ratelimit-observability runbook"
|
||||
runbook: "https://git.moleculesai.app/molecule-ai/molecule-core/blob/main/docs/engineering/ratelimit-observability.md"
|
||||
```
|
||||
|
||||
Threshold rationale: 0.1 req/s = 6/min sustained over 10min. Below
|
||||
that, a 429 is almost certainly a transient burst that the canvas's
|
||||
retry-once handler at `canvas/src/lib/api.ts:55` already absorbs. The
|
||||
30m `for:` keeps the alert from chattering on a brief blip.
|
||||
|
||||
## Companion probe script
|
||||
|
||||
For one-off triage when an operator can reproduce the problem in their
|
||||
own browser, `scripts/edge-429-probe.sh` (#62) reproduces a canvas-
|
||||
sized burst against a tenant subdomain and dumps each 429's response
|
||||
shape so the operator can distinguish workspace-server bucket overflow
|
||||
from CF/Vercel edge rate-limiting without dashboard access.
|
||||
|
||||
```sh
|
||||
./scripts/edge-429-probe.sh hongming.moleculesai.app --burst 80 --out /tmp/edge.txt
|
||||
```
|
||||
|
||||
The script's report header explains how to read the output.
|
||||
@@ -58,8 +58,11 @@ green — proves wire shape end-to-end against a real `hermes gateway run`
|
||||
subprocess + stub OpenAI-compat LLM. Caught + fixed a real `KeyError`
|
||||
in upstream `hermes_cli/tools_config.py` (PLATFORMS dict lookup
|
||||
crashed on plugin platforms) — fix on the patched fork branch
|
||||
(`HongmingWang-Rabbit/hermes-agent` `feat/platform-adapter-plugins`,
|
||||
commit `18e4849e`). Upstream PR #18775 OPEN; CONFLICTING with main.
|
||||
(`molecule-ai/hermes-agent` `feat/platform-adapter-plugins`, commit
|
||||
`18e4849e`, hosted on Gitea at
|
||||
`https://git.moleculesai.app/molecule-ai/hermes-agent` — moved from the
|
||||
suspended `github.com/HongmingWang-Rabbit/hermes-agent`, see
|
||||
`molecule-ai/internal#72`). Upstream PR #18775 OPEN; CONFLICTING with main.
|
||||
Not on critical path for our platform — patched fork is what the
|
||||
workspace image installs.
|
||||
|
||||
|
||||
+2
-1
@@ -41,6 +41,7 @@
|
||||
{"name": "medo-smoke", "repo": "Molecule-AI/molecule-ai-org-template-medo-smoke", "ref": "main"},
|
||||
{"name": "molecule-worker-gemini", "repo": "Molecule-AI/molecule-ai-org-template-molecule-worker-gemini", "ref": "main"},
|
||||
{"name": "reno-stars", "repo": "Molecule-AI/molecule-ai-org-template-reno-stars", "ref": "main"},
|
||||
{"name": "ux-ab-lab", "repo": "Molecule-AI/molecule-ai-org-template-ux-ab-lab", "ref": "main"}
|
||||
{"name": "ux-ab-lab", "repo": "Molecule-AI/molecule-ai-org-template-ux-ab-lab", "ref": "main"},
|
||||
{"name": "mock-bigorg", "repo": "Molecule-AI/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ TOP_LEVEL_MODULES = {
|
||||
"a2a_client",
|
||||
"a2a_executor",
|
||||
"a2a_mcp_server",
|
||||
"a2a_response",
|
||||
"a2a_tools",
|
||||
"a2a_tools_delegation",
|
||||
"a2a_tools_inbox",
|
||||
@@ -80,6 +81,7 @@ TOP_LEVEL_MODULES = {
|
||||
"internal_file_read",
|
||||
"main",
|
||||
"mcp_cli",
|
||||
"mcp_doctor",
|
||||
"mcp_heartbeat",
|
||||
"mcp_inbox_pollers",
|
||||
"mcp_workspace_resolver",
|
||||
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-stale-promote-pr.sh
|
||||
#
|
||||
# Scan open auto-promote PRs (base=main head=staging) for the
|
||||
# silent-block failure mode that motivated issue #2975:
|
||||
# - PR sat for hours with mergeStateStatus=BLOCKED
|
||||
# - reviewDecision=REVIEW_REQUIRED (auto-merge armed but waiting
|
||||
# on a human approval that never comes)
|
||||
#
|
||||
# When found, emit:
|
||||
# - GitHub Actions notice/warning lines (workflow summary surface)
|
||||
# - Optionally post a comment on the PR (--comment)
|
||||
#
|
||||
# Exit code is the count of stale PRs found, capped at 125 so callers
|
||||
# can detect "alarm fired" via `if ! check-stale-promote-pr.sh; then …`.
|
||||
# Exit 0 = clean, exit ≥1 = at least N stale PRs need attention.
|
||||
#
|
||||
# Used by .github/workflows/auto-promote-stale-alarm.yml. Logic lives
|
||||
# here (not inline in the workflow YAML) so we can:
|
||||
# - Unit-test it with a fixture (see test-check-stale-promote-pr.sh)
|
||||
# - Run it ad-hoc by an operator: `scripts/check-stale-promote-pr.sh`
|
||||
# - Reuse the same surface in any sibling workflow that needs the same
|
||||
# check (SSOT — one detector, many callers).
|
||||
#
|
||||
# Requires: `curl`, `jq`. `GITEA_TOKEN` (or `GITHUB_TOKEN` / `GH_TOKEN`
|
||||
# for back-compat) in the workflow context. Reads `GITHUB_SERVER_URL`
|
||||
# / `GITEA_API_URL` for the Gitea base, defaulting to
|
||||
# https://git.moleculesai.app/api/v1.
|
||||
#
|
||||
# Post-2026-05-06 (Gitea migration, issue #75): the previous version
|
||||
# called `gh pr list/view/comment`, all of which hit GitHub.com's
|
||||
# GraphQL or /api/v3 REST shapes. Gitea exposes /api/v1/ only (no
|
||||
# GraphQL → 405, no /api/v3 → 404). So this script now talks to the
|
||||
# Gitea v1 API directly via curl. The fixture-driven unit tests are
|
||||
# unchanged — they bypass the live fetch via PR_FIXTURE and still pass
|
||||
# the historical (GitHub-shape) JSON which `detect_stale` consumes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Inputs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Threshold beyond which a BLOCKED+REVIEW_REQUIRED promote PR is "stale"
|
||||
# enough to alarm. 4 hours is the floor: most legitimate gates clear
|
||||
# inside an hour, so 4× headroom is plenty for slow CI without false-
|
||||
# alarming. Override via env for tests + edge ops.
|
||||
STALE_HOURS="${STALE_HOURS:-4}"
|
||||
|
||||
# Repo defaults to GITHUB_REPOSITORY (act_runner sets this in workflow
|
||||
# context). Tests pass --repo explicitly.
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
# Whether to post a comment to the PR. Off by default to avoid noise on
|
||||
# manual ad-hoc runs; the cron workflow turns it on.
|
||||
POST_COMMENT="${POST_COMMENT:-false}"
|
||||
|
||||
# Where to read the open-PR JSON from. Empty = call Gitea live. Tests
|
||||
# point this at a fixture file.
|
||||
PR_FIXTURE="${PR_FIXTURE:-}"
|
||||
|
||||
# Where to read "now" from. Empty = real clock. Tests freeze time so
|
||||
# the staleness math is deterministic.
|
||||
NOW_OVERRIDE="${NOW_OVERRIDE:-}"
|
||||
|
||||
# Gitea API base. act_runner forwards github.server_url as
|
||||
# GITHUB_SERVER_URL; for the molecule-ai fleet that's
|
||||
# https://git.moleculesai.app. Append /api/v1 to get the REST root.
|
||||
# Override directly via GITEA_API_URL for tests / non-default hosts.
|
||||
GITEA_API_URL="${GITEA_API_URL:-${GITHUB_SERVER_URL:-https://git.moleculesai.app}/api/v1}"
|
||||
|
||||
# Token. Workflow context sets GITHUB_TOKEN; we accept GITEA_TOKEN as
|
||||
# the explicit name and GH_TOKEN for back-compat with operator habits
|
||||
# from the GitHub era. First non-empty wins.
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--comment) POST_COMMENT="true"; shift ;;
|
||||
--no-comment) POST_COMMENT="false"; shift ;;
|
||||
--fixture) PR_FIXTURE="$2"; shift 2 ;;
|
||||
--stale-hours) STALE_HOURS="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '1,/^set /p' "$0" | grep '^# ' | sed 's/^# //'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown arg: $1" >&2; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$REPO" ] && [ -z "$PR_FIXTURE" ]; then
|
||||
echo "::error::REPO env (or GITHUB_REPOSITORY) required when no fixture given" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Clock helpers — split out so tests can freeze time
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
now_epoch() {
|
||||
if [ -n "$NOW_OVERRIDE" ]; then
|
||||
printf '%s\n' "$NOW_OVERRIDE"
|
||||
else
|
||||
date -u +%s
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse RFC3339 timestamps the way Gitea / GitHub emit them (e.g.
|
||||
# "2026-05-05T23:15:00Z"). gnu-date uses -d, bsd-date uses -j -f. Cover
|
||||
# both because the workflow runs on ubuntu-latest (gnu) but operators
|
||||
# may run this script on macOS (bsd).
|
||||
to_epoch() {
|
||||
local ts="$1"
|
||||
# gnu-date path first.
|
||||
if date -u -d "$ts" +%s 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
# bsd-date fallback — strip optional fractional seconds before %S.
|
||||
local ts_clean="${ts%%.*}"
|
||||
ts_clean="${ts_clean%Z}Z"
|
||||
date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts_clean" +%s 2>/dev/null || {
|
||||
echo "::error::cannot parse timestamp: $ts" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fetch open auto-promote PRs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Gitea v1 returns PRs with the canonical Gitea shape (number, title,
|
||||
# created_at, html_url, mergeable, state). The previous GitHub-CLI
|
||||
# version returned a derived `mergeStateStatus` / `reviewDecision`
|
||||
# pair which only GitHub computes — Gitea doesn't expose them
|
||||
# natively. Rebuild equivalents:
|
||||
#
|
||||
# mergeStateStatus = BLOCKED ↔ Gitea: state==open AND mergeable==true
|
||||
# AND no APPROVED review yet
|
||||
# (i.e. branch protection is gating
|
||||
# the auto-merge pending an approval)
|
||||
# reviewDecision = REVIEW_REQUIRED ↔ Gitea: 0 APPROVED reviews
|
||||
#
|
||||
# This mirrors the SAME silent-block failure mode the GitHub version
|
||||
# detected: auto-merge armed, branch protection requires 1 review,
|
||||
# nobody's approved yet.
|
||||
#
|
||||
# Implementation: pull the open PR list base=main, then for each PR
|
||||
# pull /pulls/{n}/reviews and synthesize the GitHub-shape JSON the
|
||||
# rest of the script + the test fixtures consume.
|
||||
fetch_prs() {
|
||||
if [ -n "$PR_FIXTURE" ]; then
|
||||
cat "$PR_FIXTURE"
|
||||
return 0
|
||||
fi
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "::error::GITEA_TOKEN / GITHUB_TOKEN unset — cannot fetch PRs from $GITEA_API_URL" >&2
|
||||
return 1
|
||||
fi
|
||||
local prs_json
|
||||
prs_json="$(curl --fail-with-body -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_API_URL}/repos/${REPO}/pulls?state=open&base=main&limit=50" \
|
||||
2>/dev/null)" || {
|
||||
echo "::error::Failed to fetch PRs from ${GITEA_API_URL}/repos/${REPO}/pulls" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Filter to head=staging (the auto-promote shape) and synthesize
|
||||
# mergeStateStatus + reviewDecision per PR. Approval count via
|
||||
# /pulls/{n}/reviews. Errors fall through to 0-approvals (treated
|
||||
# as REVIEW_REQUIRED) preserving the existing "fail-safe — alarm if
|
||||
# uncertain" semantic.
|
||||
local synthesized="[]"
|
||||
while IFS= read -r pr; do
|
||||
[ -z "$pr" ] && continue
|
||||
[ "$pr" = "null" ] && continue
|
||||
local num
|
||||
num="$(printf '%s' "$pr" | jq -r '.number')"
|
||||
[ -z "$num" ] && continue
|
||||
[ "$num" = "null" ] && continue
|
||||
local approved_count
|
||||
approved_count="$(curl --fail-with-body -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_API_URL}/repos/${REPO}/pulls/${num}/reviews" 2>/dev/null \
|
||||
| jq '[.[] | select(.state == "APPROVED" and (.dismissed // false) == false)] | length' \
|
||||
2>/dev/null || echo 0)"
|
||||
local mergeable
|
||||
mergeable="$(printf '%s' "$pr" | jq -r '.mergeable')"
|
||||
local merge_state="UNKNOWN"
|
||||
local review_decision="REVIEW_REQUIRED"
|
||||
if [ "$mergeable" = "true" ]; then
|
||||
if [ "$approved_count" -ge 1 ]; then
|
||||
merge_state="CLEAN"
|
||||
review_decision="APPROVED"
|
||||
else
|
||||
# mergeable but no approving review — exactly the wedge state
|
||||
# the alarm targets.
|
||||
merge_state="BLOCKED"
|
||||
review_decision="REVIEW_REQUIRED"
|
||||
fi
|
||||
else
|
||||
# not mergeable (conflicts, behind, failed checks) — different
|
||||
# failure mode, the author owns the fix; the alarm doesn't fire.
|
||||
merge_state="DIRTY"
|
||||
review_decision="REVIEW_REQUIRED"
|
||||
fi
|
||||
synthesized="$(printf '%s' "$synthesized" \
|
||||
| jq -c --argjson pr "$pr" \
|
||||
--arg ms "$merge_state" \
|
||||
--arg rd "$review_decision" \
|
||||
'. + [{
|
||||
number: $pr.number,
|
||||
title: $pr.title,
|
||||
createdAt: $pr.created_at,
|
||||
mergeStateStatus: $ms,
|
||||
reviewDecision: $rd,
|
||||
url: $pr.html_url
|
||||
}]')"
|
||||
done < <(printf '%s' "$prs_json" \
|
||||
| jq -c '.[] | select(.head.ref == "staging")' 2>/dev/null)
|
||||
|
||||
printf '%s\n' "$synthesized"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stale detection
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Read PR list from stdin, emit one TSV line per stale PR:
|
||||
# <num>\t<age_hours>\t<url>\t<title>
|
||||
# Caller decides what to do (warn, comment, escalate).
|
||||
detect_stale() {
|
||||
local now_ts
|
||||
now_ts="$(now_epoch)"
|
||||
local stale_seconds=$((STALE_HOURS * 3600))
|
||||
|
||||
jq -r '.[] | [.number, .createdAt, .mergeStateStatus, .reviewDecision, .url, .title] | @tsv' \
|
||||
| while IFS=$'\t' read -r num created_at merge_state review_decision url title; do
|
||||
# Only alarm on the specific failure mode: BLOCKED + REVIEW_REQUIRED.
|
||||
# Other BLOCKED reasons (DIRTY, BEHIND, failed checks) are the
|
||||
# author's signal-to-fix; this script targets the silent
|
||||
# "no human reviewed yet" wedge specifically.
|
||||
[ "$merge_state" = "BLOCKED" ] || continue
|
||||
[ "$review_decision" = "REVIEW_REQUIRED" ] || continue
|
||||
|
||||
local created_ts
|
||||
created_ts="$(to_epoch "$created_at")" || continue
|
||||
local age=$((now_ts - created_ts))
|
||||
if [ "$age" -ge "$stale_seconds" ]; then
|
||||
local age_h=$((age / 3600))
|
||||
printf '%s\t%d\t%s\t%s\n' "$num" "$age_h" "$url" "$title"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Reporting
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Comment body — kept short; the issue body has the full design.
|
||||
comment_body() {
|
||||
local age_h="$1"
|
||||
cat <<EOF
|
||||
⚠️ This auto-promote PR has been BLOCKED on \`REVIEW_REQUIRED\` for **${age_h}h**.
|
||||
|
||||
Auto-merge is armed, but main's branch protection requires 1 review and no human has approved. Until someone reviews, the staging→main promote chain is wedged and downstream consumers (canvas builds, tenant redeploys) won't see new code.
|
||||
|
||||
**Action**: a human reviewer on \`@Molecule-AI/maintainers\` should approve this PR (or mark it as not ready and close).
|
||||
|
||||
Detected by \`scripts/check-stale-promote-pr.sh\` per issue #2975.
|
||||
EOF
|
||||
}
|
||||
|
||||
post_comment() {
|
||||
local pr_num="$1"
|
||||
local age_h="$2"
|
||||
if [ "$POST_COMMENT" != "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "::warning::GITEA_TOKEN unset — cannot post stale-alarm comment on PR #$pr_num" >&2
|
||||
return 0
|
||||
fi
|
||||
# Idempotency: only one alarm comment per PR. Look for the marker
|
||||
# string in existing comments before posting a new one. Gitea's
|
||||
# /repos/{owner}/{repo}/issues/{n}/comments returns the same shape
|
||||
# for issues + PRs (PRs are issues internally on Gitea, same as
|
||||
# GitHub's REST).
|
||||
local existing
|
||||
existing="$(curl --fail-with-body -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_API_URL}/repos/${REPO}/issues/${pr_num}/comments?limit=50" 2>/dev/null \
|
||||
| jq -r '.[] | select(.body | test("scripts/check-stale-promote-pr.sh per issue #2975")) | .id' \
|
||||
| head -n1)"
|
||||
if [ -n "$existing" ]; then
|
||||
echo "::notice::PR #$pr_num already has a stale-alarm comment ($existing) — not re-posting"
|
||||
return 0
|
||||
fi
|
||||
local body
|
||||
body="$(comment_body "$age_h")"
|
||||
if curl --fail-with-body -sS \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_API_URL}/repos/${REPO}/issues/${pr_num}/comments" \
|
||||
-d "$(jq -nc --arg b "$body" '{body: $b}')" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "::notice::Posted stale-alarm comment on PR #$pr_num (age=${age_h}h)"
|
||||
else
|
||||
echo "::warning::Failed to POST stale-alarm comment on PR #$pr_num" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
stale_count=0
|
||||
while IFS=$'\t' read -r num age_h url title; do
|
||||
[ -n "$num" ] || continue
|
||||
stale_count=$((stale_count + 1))
|
||||
echo "::warning title=Stale auto-promote PR::PR #$num — BLOCKED on REVIEW_REQUIRED for ${age_h}h. $url"
|
||||
{
|
||||
echo "## ⚠️ Stale auto-promote PR detected"
|
||||
echo
|
||||
echo "- PR: #$num — \`$title\`"
|
||||
echo "- Age: ${age_h}h"
|
||||
echo "- State: BLOCKED on REVIEW_REQUIRED"
|
||||
echo "- URL: $url"
|
||||
echo
|
||||
echo "Auto-merge is armed but waiting on a human review. See issue #2975."
|
||||
} >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
post_comment "$num" "$age_h"
|
||||
done < <(fetch_prs | detect_stale)
|
||||
|
||||
if [ "$stale_count" -eq 0 ]; then
|
||||
echo "::notice::No stale auto-promote PRs detected (threshold: ${STALE_HOURS}h)"
|
||||
fi
|
||||
|
||||
# Cap exit code so we don't accidentally break shells that interpret
|
||||
# >125 as signal-style. 1..N maps to "1..N stale PRs".
|
||||
exit $(( stale_count > 125 ? 125 : stale_count ))
|
||||
@@ -6,6 +6,29 @@
|
||||
# ./scripts/clone-manifest.sh <manifest.json> <ws-templates-dir> <org-templates-dir> <plugins-dir>
|
||||
#
|
||||
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
|
||||
#
|
||||
# Auth (optional):
|
||||
# When MOLECULE_GITEA_TOKEN is set, embed it as the basic-auth password so
|
||||
# private Gitea repos clone successfully. When unset, clone anonymously
|
||||
# (works only for repos that are public on git.moleculesai.app).
|
||||
#
|
||||
# This is the path the publish-workspace-server-image.yml workflow uses:
|
||||
# it injects AUTO_SYNC_TOKEN (devops-engineer persona PAT, repo:read on
|
||||
# the molecule-ai org) so the in-CI pre-clone step succeeds for ALL
|
||||
# manifest entries — including the 5 private workspace-template-* repos
|
||||
# (codex, crewai, deepagents, gemini-cli, langgraph) and all 7
|
||||
# org-template-* repos.
|
||||
#
|
||||
# The token never enters the Docker image: this script runs in the
|
||||
# trusted CI context BEFORE `docker buildx build`, populates
|
||||
# .tenant-bundle-deps/, then `Dockerfile.tenant` COPYs from there with
|
||||
# the .git directories already stripped (see line ~67 below).
|
||||
#
|
||||
# For backward compatibility — and so a fresh clone works without
|
||||
# secrets when (eventually) the workspace-template-* repos flip public —
|
||||
# the unset path remains a plain anonymous HTTPS clone. That path will
|
||||
# FAIL with "could not read Username" on private repos today; CI MUST
|
||||
# set MOLECULE_GITEA_TOKEN.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -45,11 +68,30 @@ clone_category() {
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " cloning $repo -> $target_dir/$name (ref=$ref)"
|
||||
if [ "$ref" = "main" ]; then
|
||||
git clone --depth=1 -q "https://github.com/${repo}.git" "$target_dir/$name"
|
||||
# Post-2026-05-06 GitHub-org-suspension: clone from Gitea instead.
|
||||
# manifest.json paths still read "Molecule-AI/..." (the historic
|
||||
# github.com slug); Gitea lowercases the org part to "molecule-ai/".
|
||||
# Lowercase the org segment on the fly so we don't need to rewrite
|
||||
# every manifest entry.
|
||||
repo_gitea="$(echo "$repo" | awk -F/ '{ printf "%s", tolower($1); for (i=2; i<=NF; i++) printf "/%s", $i; print "" }')"
|
||||
|
||||
# Build the clone URL. When MOLECULE_GITEA_TOKEN is set (CI path)
|
||||
# embed it as basic-auth so private repos succeed. The username
|
||||
# part ("oauth2") is conventional and ignored by Gitea — only the
|
||||
# token-as-password is verified.
|
||||
if [ -n "${MOLECULE_GITEA_TOKEN:-}" ]; then
|
||||
clone_url="https://oauth2:${MOLECULE_GITEA_TOKEN}@git.moleculesai.app/${repo_gitea}.git"
|
||||
display_url="https://oauth2:***@git.moleculesai.app/${repo_gitea}.git"
|
||||
else
|
||||
git clone --depth=1 -q --branch "$ref" "https://github.com/${repo}.git" "$target_dir/$name"
|
||||
clone_url="https://git.moleculesai.app/${repo_gitea}.git"
|
||||
display_url="$clone_url"
|
||||
fi
|
||||
|
||||
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
|
||||
if [ "$ref" = "main" ]; then
|
||||
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
|
||||
else
|
||||
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
|
||||
fi
|
||||
CLONED=$((CLONED + 1))
|
||||
i=$((i + 1))
|
||||
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# edge-429-probe.sh — capture 429 origin (workspace-server vs CF/Vercel edge)
|
||||
# during a simulated canvas-burst against a tenant subdomain.
|
||||
#
|
||||
# Issue molecule-core#62. The post-#60 verification step asks an
|
||||
# operator with CF/Vercel dashboard access to confirm whether the
|
||||
# layout-chunk 429s observed in DevTools were:
|
||||
# (a) workspace-server bucket overflow (closes once #60 deploys), or
|
||||
# (b) actual edge-layer rate-limiting (CF or Vercel).
|
||||
#
|
||||
# This script doesn't need dashboard access. It reproduces the burst
|
||||
# pattern locally and dumps every 429's response shape so the operator
|
||||
# can distinguish (a) from (b) by inspection: workspace-server emits a
|
||||
# JSON body, CF emits HTML, Vercel emits a different HTML. Headers tell
|
||||
# the same story (cf-ray vs x-vercel-*).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/edge-429-probe.sh <tenant-host> [--burst N] [--waves N] [--pause SECS] [--out file]
|
||||
#
|
||||
# Example:
|
||||
# ./scripts/edge-429-probe.sh hongming.moleculesai.app --burst 80 --out /tmp/edge.txt
|
||||
#
|
||||
# The script is read-only against the target — it only issues GETs to
|
||||
# public-by-design endpoints. No mutating requests, no credential use.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Help / usage handling first, before positional capture ────────────────────
|
||||
case "${1:-}" in
|
||||
-h|--help|"")
|
||||
sed -n '/^# edge-429-probe.sh/,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
HOST="$1"; shift
|
||||
BURST=80
|
||||
WAVES=3
|
||||
WAVE_PAUSE=2
|
||||
OUT=""
|
||||
|
||||
while [ "${1:-}" != "" ]; do
|
||||
case "$1" in
|
||||
--burst) BURST="$2"; shift 2 ;;
|
||||
--waves) WAVES="$2"; shift 2 ;;
|
||||
--pause) WAVE_PAUSE="$2"; shift 2 ;;
|
||||
--out) OUT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '/^# edge-429-probe.sh/,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Endpoint discovery ────────────────────────────────────────────────────────
|
||||
echo "→ Discovering a layout-chunk URL from canvas root..." >&2
|
||||
ROOT_BODY=$(curl -fsSL --max-time 10 "https://${HOST}/" 2>/dev/null || true)
|
||||
LAYOUT_PATH=$(echo "$ROOT_BODY" \
|
||||
| grep -oE '/_next/static/chunks/layout-[A-Za-z0-9_-]+\.js' \
|
||||
| head -1 || true)
|
||||
if [ -z "$LAYOUT_PATH" ]; then
|
||||
LAYOUT_PATH="/_next/static/chunks/layout-probe-not-found.js"
|
||||
echo " (no layout chunk discovered — using sentinel path; 404 on this is expected)" >&2
|
||||
else
|
||||
echo " layout chunk: $LAYOUT_PATH" >&2
|
||||
fi
|
||||
|
||||
# Probe URL: a generic activity endpoint. The rate-limiter middleware
|
||||
# runs BEFORE workspace-id validation, so unauth/invalid-id requests
|
||||
# still hit the bucket.
|
||||
ACTIVITY_PATH="/workspaces/00000000-0000-0000-0000-000000000000/activity?probe=edge-429"
|
||||
|
||||
# ── Fire one curl, write a single-line JSON-ish status record to stdout ──────
|
||||
# Inlined into xargs as a heredoc-style command rather than a function so
|
||||
# the function-export pitfalls (some shells lose `export -f` across xargs)
|
||||
# don't apply. Each output line is a parseable record; failed curls emit
|
||||
# a curl_err record so request volume is preserved.
|
||||
TMP_RESULTS="$(mktemp -t edge-429-probe.XXXXXX)"
|
||||
trap 'rm -f "$TMP_RESULTS"' EXIT
|
||||
|
||||
run_burst() {
|
||||
# $1 = path; $2 = label; $3 = wave_id
|
||||
local path="$1" label="$2" wave="$3"
|
||||
local i
|
||||
for i in $(seq 1 "$BURST"); do
|
||||
{
|
||||
out=$(curl -sS --max-time 10 -o /dev/null \
|
||||
-w 'status=%{http_code} size=%{size_download} time=%{time_total} server=%{header.server} cf_ray=%{header.cf-ray} x_vercel=%{header.x-vercel-id} retry_after=%{header.retry-after} content_type=%{header.content-type} x_ratelimit_limit=%{header.x-ratelimit-limit} x_ratelimit_remaining=%{header.x-ratelimit-remaining} x_ratelimit_reset=%{header.x-ratelimit-reset}\n' \
|
||||
"https://${HOST}${path}" 2>/dev/null) || out="status=curl_err"
|
||||
printf 'label=%s-%s-%s %s\n' "$label" "$wave" "$i" "$out" >> "$TMP_RESULTS"
|
||||
} &
|
||||
done
|
||||
wait
|
||||
}
|
||||
|
||||
emit() {
|
||||
if [ -n "$OUT" ]; then
|
||||
printf '%s\n' "$*" >> "$OUT"
|
||||
else
|
||||
printf '%s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$OUT" ]; then : > "$OUT"; fi
|
||||
|
||||
emit "# edge-429-probe report"
|
||||
emit "# host=$HOST burst=$BURST waves=$WAVES pause=${WAVE_PAUSE}s"
|
||||
emit "# layout_path=$LAYOUT_PATH"
|
||||
emit "# activity_path=$ACTIVITY_PATH"
|
||||
emit "# generated=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
emit ""
|
||||
|
||||
for wave in $(seq 1 "$WAVES"); do
|
||||
emit "## wave $wave"
|
||||
: > "$TMP_RESULTS"
|
||||
run_burst "$LAYOUT_PATH" "layout" "$wave"
|
||||
run_burst "$ACTIVITY_PATH" "activity" "$wave"
|
||||
while read -r line; do
|
||||
emit " $line"
|
||||
done < "$TMP_RESULTS"
|
||||
if [ "$wave" -lt "$WAVES" ]; then
|
||||
sleep "$WAVE_PAUSE"
|
||||
fi
|
||||
done
|
||||
|
||||
emit ""
|
||||
emit "## summary — how to read the report"
|
||||
emit "# status=429 + content_type starts with application/json + x_ratelimit_limit set"
|
||||
emit "# => workspace-server bucket overflow. Closes when #60 deploys."
|
||||
emit "# status=429 + cf_ray set + content_type=text/html"
|
||||
emit "# => Cloudflare WAF / rate-limit. Audit dashboard rules per #62."
|
||||
emit "# status=429 + x_vercel set + content_type=text/html"
|
||||
emit "# => Vercel edge / Bot Fight Mode. Audit Vercel project per #62."
|
||||
emit "# status=429 with no server/cf_ray/x_vercel"
|
||||
emit "# => corporate proxy or VPN. Not actionable in this repo."
|
||||
|
||||
if [ -n "$OUT" ]; then
|
||||
echo "→ Report written to $OUT" >&2
|
||||
# Match only data lines (begin with two-space indent + "label="),
|
||||
# not the summary's reference text which also mentions "status=429".
|
||||
# grep -c outputs "0" + exits 1 when zero matches; `|| true` masks
|
||||
# the exit status so set -e doesn't trip without losing the count.
|
||||
total=$(grep -c '^ label=' "$OUT" 2>/dev/null || true)
|
||||
total429=$(grep -c '^ label=.*status=429' "$OUT" 2>/dev/null || true)
|
||||
total=${total:-0}
|
||||
total429=${total429:-0}
|
||||
echo "→ Totals: ${total429} of ${total} requests returned 429" >&2
|
||||
if [ "${total429}" -gt 0 ]; then
|
||||
echo "→ Per-label 429 counts:" >&2
|
||||
grep '^ label=.*status=429' "$OUT" \
|
||||
| sed -E 's/^ label=([^-]+).*/ \1/' \
|
||||
| sort | uniq -c >&2
|
||||
fi
|
||||
fi
|
||||
@@ -19,9 +19,15 @@ Exit codes:
|
||||
0 — no collisions
|
||||
1 — collision detected; output names the conflicting PR(s) for the author
|
||||
|
||||
Designed to run from a GitHub Actions PR check. Reads PR metadata via the
|
||||
GitHub CLI (gh) which is preinstalled on ubuntu-latest runners. Runs in
|
||||
under 10s against a typical PR.
|
||||
Designed to run from a Gitea Actions PR check. Reads PR metadata via direct
|
||||
HTTP calls to Gitea's REST API (`/api/v1/`), which on the molecule-ai fleet
|
||||
lives at https://git.moleculesai.app. Runs in under 10s against a typical PR.
|
||||
|
||||
Post-2026-05-06 (Gitea migration, issue #75): the previous version called
|
||||
the GitHub CLI (``gh pr list``, ``gh pr diff``). On Gitea those calls hit
|
||||
either the GraphQL endpoint (HTTP 405) or /api/v3 (HTTP 404). This module
|
||||
now talks to /api/v1 directly via urllib so it works against any Gitea
|
||||
host without a `gh` install or extra dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,12 +37,70 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
MIGRATIONS_DIR = "workspace-server/migrations"
|
||||
MIGRATION_FILE_RE = re.compile(r"^(\d+)_[^/]+\.(up|down)\.sql$")
|
||||
|
||||
|
||||
def _gitea_api_url() -> str:
|
||||
"""Resolve the Gitea API base URL.
|
||||
|
||||
act_runner forwards github.server_url as GITHUB_SERVER_URL; for the
|
||||
molecule-ai fleet that's https://git.moleculesai.app. Append /api/v1
|
||||
to get the REST root. Override directly via GITEA_API_URL for tests
|
||||
or non-default hosts.
|
||||
"""
|
||||
env_override = os.environ.get("GITEA_API_URL", "").rstrip("/")
|
||||
if env_override:
|
||||
return env_override
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "https://git.moleculesai.app").rstrip("/")
|
||||
return f"{server}/api/v1"
|
||||
|
||||
|
||||
def _gitea_token() -> str:
|
||||
"""Resolve the Gitea token from env. GITEA_TOKEN wins; falls back
|
||||
to GITHUB_TOKEN (set by act_runner) and GH_TOKEN (operator habit
|
||||
from the GitHub era)."""
|
||||
return (
|
||||
os.environ.get("GITEA_TOKEN")
|
||||
or os.environ.get("GITHUB_TOKEN")
|
||||
or os.environ.get("GH_TOKEN")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _gitea_get(path: str, params: dict[str, str] | None = None) -> bytes | None:
|
||||
"""GET against /api/v1; returns response body or None on HTTP error.
|
||||
|
||||
Errors return None (not raise) because callers handle missing data
|
||||
by emitting an actionable workflow message rather than crashing the
|
||||
PR check on a transient API blip.
|
||||
"""
|
||||
base = _gitea_api_url()
|
||||
qs = ""
|
||||
if params:
|
||||
qs = "?" + urllib.parse.urlencode(params)
|
||||
url = f"{base}/{path.lstrip('/')}{qs}"
|
||||
req = urllib.request.Request(url)
|
||||
token = _gitea_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.stderr.write(f"Gitea API HTTP {e.code} on {path}: {e.reason}\n")
|
||||
return None
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
sys.stderr.write(f"Gitea API network error on {path}: {e}\n")
|
||||
return None
|
||||
|
||||
|
||||
def run(cmd: list[str], check: bool = True) -> str:
|
||||
"""Run a subprocess and return stdout. Raise on non-zero when check=True."""
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
@@ -96,32 +160,49 @@ def open_prs_with_migration_prefix(
|
||||
repo: str, prefix: int, exclude_pr: int
|
||||
) -> list[dict]:
|
||||
"""Return open PRs (other than `exclude_pr`) that add a migration with
|
||||
`prefix`. Uses `gh pr diff` per PR — we only need to walk PRs that are
|
||||
actually in flight, so the cost is bounded by open-PR count.
|
||||
`prefix`. Walks open PRs via Gitea's `/repos/{owner}/{repo}/pulls` and
|
||||
pulls each one's changed-file list via `/pulls/{n}/files`. The cost is
|
||||
bounded by open-PR count, which is small (<100) on this repo. The
|
||||
return shape mimics the GitHub CLI's `--json number,headRefName`:
|
||||
``[{"number": int, "headRefName": str}, ...]``.
|
||||
"""
|
||||
out = run([
|
||||
"gh", "pr", "list", "--repo", repo, "--state", "open",
|
||||
"--json", "number,headRefName", "--limit", "100",
|
||||
])
|
||||
prs = json.loads(out)
|
||||
body = _gitea_get(
|
||||
f"repos/{repo}/pulls",
|
||||
{"state": "open", "limit": "50"},
|
||||
)
|
||||
if body is None:
|
||||
# Best-effort: a transient Gitea blip shouldn't fail the PR
|
||||
# check (the base-branch collision check runs locally and is
|
||||
# the more common failure mode).
|
||||
return []
|
||||
prs = json.loads(body)
|
||||
matches: list[dict] = []
|
||||
for pr in prs:
|
||||
num = pr["number"]
|
||||
if num == exclude_pr:
|
||||
continue
|
||||
try:
|
||||
files = run([
|
||||
"gh", "pr", "diff", str(num), "--repo", repo, "--name-only",
|
||||
], check=False)
|
||||
except Exception: # noqa: BLE001
|
||||
# Gitea returns the head ref under .head.ref (REST shape);
|
||||
# GitHub CLI's --json headRefName flattens it. Normalize on
|
||||
# the way out so callers see the historical shape.
|
||||
head_ref_name = (pr.get("head") or {}).get("ref", "")
|
||||
files_body = _gitea_get(f"repos/{repo}/pulls/{num}/files", {"limit": "100"})
|
||||
if files_body is None:
|
||||
continue
|
||||
for raw in files.splitlines():
|
||||
try:
|
||||
files = json.loads(files_body)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for f in files:
|
||||
# Gitea's /pulls/{n}/files returns objects with `.filename`
|
||||
# (same as GitHub's REST). Older Gitea versions emit
|
||||
# `.name` instead — handle both.
|
||||
raw = f.get("filename") or f.get("name") or ""
|
||||
path = Path(raw.strip())
|
||||
if not path.name:
|
||||
continue
|
||||
m = MIGRATION_FILE_RE.match(path.name)
|
||||
if m and int(m.group(1)) == prefix:
|
||||
matches.append(pr)
|
||||
matches.append({"number": num, "headRefName": head_ref_name})
|
||||
break
|
||||
return matches
|
||||
|
||||
@@ -138,7 +219,10 @@ def main() -> int:
|
||||
pr_number = int(pr_number_env)
|
||||
base_ref = os.environ.get("BASE_REF", "origin/staging")
|
||||
head_ref = os.environ.get("HEAD_REF", "HEAD")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "Molecule-AI/molecule-core")
|
||||
# Default kept lowercase to match the Gitea-canonical org name
|
||||
# (post-2026-05-06 migration). Tests + workflow context override
|
||||
# via GITHUB_REPOSITORY which act_runner sets per-run.
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
|
||||
|
||||
added = migrations_in_diff(base_ref, head_ref)
|
||||
if not added:
|
||||
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/test-check-stale-promote-pr.sh
|
||||
#
|
||||
# Exhaustive bash unit tests for check-stale-promote-pr.sh.
|
||||
# Goal: 100% branch coverage on the detector logic.
|
||||
#
|
||||
# Each case writes a fixture JSON, freezes the clock with NOW_OVERRIDE,
|
||||
# runs the script with --fixture + --no-comment (so we don't try to
|
||||
# actually call `gh pr comment`), and asserts on stdout/exit code.
|
||||
#
|
||||
# Run: bash scripts/test-check-stale-promote-pr.sh
|
||||
# Expected: "All N tests passed" + exit 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT="$(cd "$(dirname "$0")" && pwd)/check-stale-promote-pr.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Frozen "now" — 2026-05-06T05:00:00Z. Compute dynamically so the
|
||||
# tests stay correct regardless of platform-specific date semantics
|
||||
# (gnu vs bsd) and any author math errors on the epoch.
|
||||
if FROZEN_NOW="$(date -u -d '2026-05-06T05:00:00Z' +%s 2>/dev/null)"; then
|
||||
: # gnu-date worked
|
||||
elif FROZEN_NOW="$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2026-05-06T05:00:00Z' +%s 2>/dev/null)"; then
|
||||
: # bsd-date worked
|
||||
else
|
||||
echo "FATAL: cannot compute FROZEN_NOW on this platform" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_script() {
|
||||
# Args: <fixture-file>
|
||||
# Returns stdout + exit code via a known marker.
|
||||
local fixture="$1"
|
||||
shift
|
||||
set +e
|
||||
NOW_OVERRIDE="$FROZEN_NOW" \
|
||||
POST_COMMENT="false" \
|
||||
bash "$SCRIPT" --fixture "$fixture" "$@" 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
echo "EXIT_CODE=$rc"
|
||||
}
|
||||
|
||||
assert_pass() {
|
||||
local name="$1"
|
||||
local got="$2"
|
||||
local want_pattern="$3"
|
||||
if printf '%s' "$got" | grep -qE "$want_pattern"; then
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s\n' "$name"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
printf ' ✗ %s\n want pattern: %s\n got:\n%s\n' "$name" "$want_pattern" "$got"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_match() {
|
||||
local name="$1"
|
||||
local got="$2"
|
||||
local bad_pattern="$3"
|
||||
if printf '%s' "$got" | grep -qE "$bad_pattern"; then
|
||||
FAIL=$((FAIL + 1))
|
||||
printf ' ✗ %s\n bad pattern matched: %s\n got:\n%s\n' "$name" "$bad_pattern" "$got"
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s\n' "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Test cases
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "1. Empty PR list — clean exit"
|
||||
echo '[]' > "$TMP/empty.json"
|
||||
got=$(run_script "$TMP/empty.json")
|
||||
assert_pass "empty-no-warning" "$got" "No stale auto-promote PRs detected"
|
||||
assert_pass "empty-exit-zero" "$got" "EXIT_CODE=0"
|
||||
|
||||
echo
|
||||
echo "2. Single PR, BLOCKED+REVIEW_REQUIRED, 5h old — fires alarm"
|
||||
cat > "$TMP/stale1.json" <<EOF
|
||||
[{
|
||||
"number": 2963,
|
||||
"title": "staging → main",
|
||||
"createdAt": "2026-05-06T00:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "REVIEW_REQUIRED",
|
||||
"url": "https://github.com/test/test/pull/2963"
|
||||
}]
|
||||
EOF
|
||||
got=$(run_script "$TMP/stale1.json")
|
||||
assert_pass "stale1-warning" "$got" "Stale auto-promote PR"
|
||||
assert_pass "stale1-pr-number" "$got" "PR #2963"
|
||||
assert_pass "stale1-age" "$got" "for 5h"
|
||||
assert_pass "stale1-exit-1" "$got" "EXIT_CODE=1"
|
||||
|
||||
echo
|
||||
echo "3. Same PR but only 3h old — under threshold, NO alarm"
|
||||
cat > "$TMP/young.json" <<EOF
|
||||
[{
|
||||
"number": 100,
|
||||
"title": "fresh promote",
|
||||
"createdAt": "2026-05-06T02:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "REVIEW_REQUIRED",
|
||||
"url": "https://github.com/test/test/pull/100"
|
||||
}]
|
||||
EOF
|
||||
got=$(run_script "$TMP/young.json")
|
||||
assert_pass "young-no-alarm" "$got" "No stale auto-promote PRs"
|
||||
assert_pass "young-exit-zero" "$got" "EXIT_CODE=0"
|
||||
assert_no_match "young-no-warning" "$got" "Stale auto-promote PR"
|
||||
|
||||
echo
|
||||
echo "4. PR is BLOCKED but for the wrong reason (DIRTY, not REVIEW_REQUIRED)"
|
||||
cat > "$TMP/dirty.json" <<EOF
|
||||
[{
|
||||
"number": 200,
|
||||
"title": "needs rebase",
|
||||
"createdAt": "2026-05-06T00:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "APPROVED",
|
||||
"url": "https://github.com/test/test/pull/200"
|
||||
}]
|
||||
EOF
|
||||
got=$(run_script "$TMP/dirty.json")
|
||||
assert_pass "dirty-no-alarm" "$got" "No stale auto-promote PRs"
|
||||
assert_pass "dirty-exit-zero" "$got" "EXIT_CODE=0"
|
||||
|
||||
echo
|
||||
echo "5. PR is APPROVED but mergeStateStatus is CLEAN — NOT alarming"
|
||||
cat > "$TMP/clean.json" <<EOF
|
||||
[{
|
||||
"number": 300,
|
||||
"title": "all green",
|
||||
"createdAt": "2026-05-06T00:00:00Z",
|
||||
"mergeStateStatus": "CLEAN",
|
||||
"reviewDecision": "APPROVED",
|
||||
"url": "https://github.com/test/test/pull/300"
|
||||
}]
|
||||
EOF
|
||||
got=$(run_script "$TMP/clean.json")
|
||||
assert_pass "clean-no-alarm" "$got" "No stale auto-promote PRs"
|
||||
|
||||
echo
|
||||
echo "6. Multiple PRs — only the BLOCKED+REVIEW_REQUIRED+old one alarms"
|
||||
cat > "$TMP/mixed.json" <<EOF
|
||||
[
|
||||
{
|
||||
"number": 100,
|
||||
"title": "fresh",
|
||||
"createdAt": "2026-05-06T04:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "REVIEW_REQUIRED",
|
||||
"url": "https://x/100"
|
||||
},
|
||||
{
|
||||
"number": 200,
|
||||
"title": "stale + alarming",
|
||||
"createdAt": "2026-05-05T20:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "REVIEW_REQUIRED",
|
||||
"url": "https://x/200"
|
||||
},
|
||||
{
|
||||
"number": 300,
|
||||
"title": "approved + clean",
|
||||
"createdAt": "2026-05-05T20:00:00Z",
|
||||
"mergeStateStatus": "CLEAN",
|
||||
"reviewDecision": "APPROVED",
|
||||
"url": "https://x/300"
|
||||
}
|
||||
]
|
||||
EOF
|
||||
got=$(run_script "$TMP/mixed.json")
|
||||
assert_pass "mixed-only-200" "$got" "PR #200"
|
||||
assert_no_match "mixed-not-100" "$got" "PR #100"
|
||||
assert_no_match "mixed-not-300" "$got" "PR #300"
|
||||
assert_pass "mixed-exit-1" "$got" "EXIT_CODE=1"
|
||||
|
||||
echo
|
||||
echo "7. Custom STALE_HOURS via --stale-hours overrides threshold"
|
||||
got=$(run_script "$TMP/young.json" --stale-hours 1)
|
||||
assert_pass "custom-threshold-fires" "$got" "PR #100"
|
||||
assert_pass "custom-threshold-exit-1" "$got" "EXIT_CODE=1"
|
||||
|
||||
echo
|
||||
echo "8. Two stale PRs — exit code reflects count"
|
||||
cat > "$TMP/two-stale.json" <<EOF
|
||||
[
|
||||
{
|
||||
"number": 200,
|
||||
"title": "stale-A",
|
||||
"createdAt": "2026-05-05T20:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "REVIEW_REQUIRED",
|
||||
"url": "https://x/200"
|
||||
},
|
||||
{
|
||||
"number": 201,
|
||||
"title": "stale-B",
|
||||
"createdAt": "2026-05-05T19:00:00Z",
|
||||
"mergeStateStatus": "BLOCKED",
|
||||
"reviewDecision": "REVIEW_REQUIRED",
|
||||
"url": "https://x/201"
|
||||
}
|
||||
]
|
||||
EOF
|
||||
got=$(run_script "$TMP/two-stale.json")
|
||||
assert_pass "two-stale-exit-2" "$got" "EXIT_CODE=2"
|
||||
|
||||
echo
|
||||
echo "9. Help text is shown for --help"
|
||||
set +e
|
||||
help_out=$(bash "$SCRIPT" --help 2>&1)
|
||||
help_rc=$?
|
||||
set -e
|
||||
assert_pass "help-exits-zero" "EXIT_CODE=$help_rc" "EXIT_CODE=0"
|
||||
assert_pass "help-mentions-issue" "$help_out" "issue #2975"
|
||||
|
||||
echo
|
||||
echo "10. Unknown arg exits 64 (EX_USAGE)"
|
||||
set +e
|
||||
bad_out=$(bash "$SCRIPT" --bogus 2>&1)
|
||||
bad_rc=$?
|
||||
set -e
|
||||
assert_pass "unknown-arg-rc" "EXIT_CODE=$bad_rc" "EXIT_CODE=64"
|
||||
|
||||
echo
|
||||
echo "11. Missing repo + missing fixture exits 2"
|
||||
set +e
|
||||
out=$(REPO="" bash "$SCRIPT" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
assert_pass "no-repo-exit-2" "EXIT_CODE=$rc" "EXIT_CODE=2"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "─────────────────────────────────────────────"
|
||||
echo "Tests: $PASS passed, $FAIL failed"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "All tests passed."
|
||||
@@ -157,6 +157,43 @@ A2A_RESP=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a
|
||||
}')
|
||||
|
||||
check "poll-mode A2A returns queued status" '"status":"queued"' "$A2A_RESP"
|
||||
|
||||
# ---------- Phase 3.5: Python parser classifies queued envelope correctly ----------
|
||||
# (#2967) — server emits the queued envelope, the wheel's a2a_response.parse()
|
||||
# MUST classify it as the Queued variant, not Malformed. Pre-#2967 the bare
|
||||
# message/send parser in a2a_client.py:587 misclassified this and returned
|
||||
# "[A2A_ERROR] unexpected response shape", which broke external↔external A2A
|
||||
# on poll-mode peers.
|
||||
#
|
||||
# This phase exercises the actual on-the-wire response from a real
|
||||
# workspace-server (NOT a mocked dict) through the same module the production
|
||||
# wheel ships, so a regression in either the server emit shape OR the client
|
||||
# parser fails this E2E.
|
||||
|
||||
echo ""
|
||||
echo "--- Phase 3.5: Python parser classifies real server response (#2967) ---"
|
||||
|
||||
# Pipe the queued response captured above through a2a_response.parse and
|
||||
# assert the classification. WORKSPACE_ID is required at module import
|
||||
# time but irrelevant to this parsing call (any UUID is fine).
|
||||
PARSE_RESULT=$(WORKSPACE_ID="00000000-0000-0000-0000-000000000001" \
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sys.path.insert(0, '$(cd "$(dirname "$0")/../../workspace" && pwd)')
|
||||
import a2a_response
|
||||
data = json.loads(r'''$A2A_RESP''')
|
||||
v = a2a_response.parse(data)
|
||||
print(type(v).__name__)
|
||||
if isinstance(v, a2a_response.Queued):
|
||||
print(f'method={v.method} delivery_mode={v.delivery_mode}')
|
||||
")
|
||||
|
||||
check_eq "Python parser classifies real server response as Queued" \
|
||||
"Queued" "$(printf '%s' "$PARSE_RESULT" | head -n1)"
|
||||
check "Queued variant captures method=message/send" \
|
||||
"method=message/send" "$PARSE_RESULT"
|
||||
check "Queued variant captures delivery_mode=poll" \
|
||||
"delivery_mode=poll" "$PARSE_RESULT"
|
||||
check "queued response echoes delivery_mode=poll" '"delivery_mode":"poll"' "$A2A_RESP"
|
||||
check "queued response echoes the JSON-RPC method" '"method":"message/send"' "$A2A_RESP"
|
||||
|
||||
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/branch-protection/check_name_parity.sh — assert every required-
|
||||
# check name listed in apply.sh maps to a workflow job whose "always
|
||||
# emits this status" shape is intact.
|
||||
#
|
||||
# Closes #144 / encodes the saved memory
|
||||
# feedback_branch_protection_check_name_parity:
|
||||
#
|
||||
# "Path filters (e.g., detect-changes → conditional skip) silently
|
||||
# break branch protection because no job emits the protected
|
||||
# sentinel status when path-filter returns false."
|
||||
#
|
||||
# Two safe shapes for a required-check job:
|
||||
#
|
||||
# 1. Single-job-with-per-step-if (path-filter case):
|
||||
# The workflow has NO top-level `paths:` filter; the always-running
|
||||
# job has steps gated on `if: needs.<gate>.outputs.<flag> == 'true'`
|
||||
# so the no-op step alone fires when paths exclude the commit.
|
||||
# Used by ci.yml's Platform/Canvas/Python/Shellcheck and by
|
||||
# e2e-api.yml / e2e-staging-canvas.yml / runtime-prbuild-compat.yml.
|
||||
#
|
||||
# 2. Aggregator-with-needs+always() (matrix-refactor case):
|
||||
# An aggregator job named after the protected check `needs:` the
|
||||
# matrix children + uses `if: always()` + checks each child's
|
||||
# result. (Not currently in this repo but supported.)
|
||||
#
|
||||
# Unsafe shape this script catches:
|
||||
# - Workflow has top-level `paths:` filter AND the protected check
|
||||
# name is on a single job. When paths-filter excludes a commit, the
|
||||
# workflow doesn't fire — branch protection waits forever.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — every required check name has at least one safe-shape match
|
||||
# 1 — a required name has no match OR matches an unsafe shape
|
||||
# 2 — script-internal error (apply.sh missing, awk failure, etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
WORKFLOWS_DIR="$REPO_ROOT/.github/workflows"
|
||||
APPLY_SH="$SCRIPT_DIR/apply.sh"
|
||||
|
||||
if [[ ! -f "$APPLY_SH" ]]; then
|
||||
echo "check_name_parity: missing apply.sh at $APPLY_SH" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$WORKFLOWS_DIR" ]]; then
|
||||
echo "check_name_parity: missing .github/workflows at $WORKFLOWS_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ─── Extract the union of required check names from apply.sh ──────
|
||||
# apply.sh has STAGING_CHECKS and MAIN_CHECKS heredocs; union them so
|
||||
# we audit any name that gates EITHER branch. Filters out blank lines
|
||||
# and the heredoc end marker. Sorted + uniq so the audit output is stable.
|
||||
#
|
||||
# Captures the heredoc end-marker dynamically from the `<<'MARKER'`
|
||||
# token on the opening line — the token can be `EOF` (production
|
||||
# apply.sh), `EOF2` (test fixtures with nested heredocs), or any other
|
||||
# bash-legal identifier. Without dynamic extraction, test fixtures
|
||||
# with nested heredocs would either skip-capture (wrong end marker)
|
||||
# or capture the inner end marker as a stray check name.
|
||||
#
|
||||
# Two-step approach to keep awk-portable across BSD awk (macOS) and
|
||||
# gawk (Linux): grep finds the heredoc-opening lines, sed extracts the
|
||||
# marker, then awk does the capture. Pure-awk attempts hit BSD-vs-GNU
|
||||
# regex/variable-init differences that regress silently — this shape
|
||||
# stays in POSIX-portable territory.
|
||||
extract_heredoc_block() {
|
||||
local file="$1"
|
||||
local marker="$2"
|
||||
awk -v marker="$marker" '
|
||||
$0 ~ "<<.?" marker { capture=1; next }
|
||||
$0 == marker && capture { capture=0; next }
|
||||
capture && NF { print }
|
||||
' "$file"
|
||||
}
|
||||
|
||||
# Find every heredoc-end marker used in apply.sh (typically just EOF
|
||||
# in the production script, but EOF2 / TAG / ABC are all valid in
|
||||
# fixtures or future expansions). Each marker maps to one or more
|
||||
# heredoc blocks; we union all of them.
|
||||
markers=$(grep -E "<<['\"]?[A-Za-z0-9_]+['\"]?[[:space:]]*\\|\\|" "$APPLY_SH" \
|
||||
| sed -E "s/.*<<['\"]?([A-Za-z0-9_]+)['\"]?.*/\\1/" \
|
||||
| sort -u)
|
||||
|
||||
required_names=""
|
||||
while IFS= read -r marker; do
|
||||
[[ -z "$marker" ]] && continue
|
||||
block=$(extract_heredoc_block "$APPLY_SH" "$marker")
|
||||
if [[ -n "$block" ]]; then
|
||||
required_names+="$block"$'\n'
|
||||
fi
|
||||
done <<< "$markers"
|
||||
|
||||
required_names=$(printf '%s' "$required_names" | sort -u | sed '/^$/d')
|
||||
|
||||
if [[ -z "$required_names" ]]; then
|
||||
echo "check_name_parity: failed to extract required check names from apply.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ─── For each required name, find the workflow file that owns it ──
|
||||
# A workflow "owns" a name if any `name:` line in the file equals the
|
||||
# required name. We look at job-level names AND the workflow-level
|
||||
# `name:` (the latter prefixes "Analyze" jobs in codeql.yml).
|
||||
#
|
||||
# Then we check whether the owning workflow has a top-level `paths:`
|
||||
# filter. The unsafe shape is:
|
||||
# - top-level paths: filter present
|
||||
# - AND the named job is gated only at the workflow level (no per-
|
||||
# step `if:` gates)
|
||||
#
|
||||
# Distinguishing "no `paths:` filter" from "paths: filter + per-step
|
||||
# gating" requires parsing the YAML semantics. We do it heuristically:
|
||||
#
|
||||
# - "no top-level paths:" → safe by construction (workflow always
|
||||
# fires)
|
||||
# - "paths: present" → check that the matching job has at
|
||||
# least one `if: needs.<x>.outputs`
|
||||
# step gate. If yes, that's the
|
||||
# single-job-with-per-step-if shape.
|
||||
# If no, flag as unsafe.
|
||||
#
|
||||
# Heuristic so it stays a portable bash + awk + grep tool — full YAML
|
||||
# parsing would need yq which isn't a dependency. The known unsafe
|
||||
# shape (workflow-level paths: AND no per-step if-gates) is what we're
|
||||
# trying to catch.
|
||||
|
||||
failed=0
|
||||
declare -a unsafe_findings=()
|
||||
|
||||
while IFS= read -r name; do
|
||||
[[ -z "$name" ]] && continue
|
||||
# Find every workflow file that contains a job with `name: <name>` or
|
||||
# whose top-level workflow `name:` plus matrix substitution would
|
||||
# produce <name>. Need to be careful about quoting — YAML allows
|
||||
# `name: Foo`, `name: "Foo"`, `name: 'Foo'`. Strip quotes.
|
||||
matches=()
|
||||
while IFS= read -r f; do
|
||||
# Look for an exact `name:` match (anywhere in the file). The
|
||||
# workflow-level name line is at column 0; job-level names are
|
||||
# indented. Either is acceptable for parity — what matters is
|
||||
# whether the EMITTED check-run name is the one we required.
|
||||
# Strip surrounding quotes/whitespace before comparing.
|
||||
if awk -v want="$name" '
|
||||
/^[[:space:]]*name:[[:space:]]*/ {
|
||||
line = $0
|
||||
sub(/^[[:space:]]*name:[[:space:]]*/, "", line)
|
||||
# Strip surrounding " or '\''
|
||||
gsub(/^["\047]|["\047]$/, "", line)
|
||||
# Strip trailing whitespace + comment
|
||||
sub(/[[:space:]]*#.*$/, "", line)
|
||||
sub(/[[:space:]]+$/, "", line)
|
||||
if (line == want) found = 1
|
||||
}
|
||||
END { exit !found }
|
||||
' "$f"; then
|
||||
matches+=("$f")
|
||||
fi
|
||||
done < <(find "$WORKFLOWS_DIR" -name '*.yml' -o -name '*.yaml')
|
||||
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
# Special case — Analyze (go/javascript-typescript/python) is
|
||||
# generated by codeql.yml's matrix expansion of `Analyze (${{
|
||||
# matrix.language }})`. Don't flag those as missing if codeql.yml
|
||||
# exists with the expected base name.
|
||||
case "$name" in
|
||||
"Analyze (go)"|"Analyze (javascript-typescript)"|"Analyze (python)")
|
||||
# shellcheck disable=SC2016
|
||||
# The literal `${{ matrix.language }}` is the GHA template
|
||||
# syntax we're searching FOR — not a shell expansion. SC2016
|
||||
# would have us add quotes that defeat the search.
|
||||
if [[ -f "$WORKFLOWS_DIR/codeql.yml" ]] && \
|
||||
grep -q 'name: Analyze (${{[[:space:]]*matrix.language[[:space:]]*}})' "$WORKFLOWS_DIR/codeql.yml"; then
|
||||
matches=("$WORKFLOWS_DIR/codeql.yml")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
unsafe_findings+=("MISSING: required check name '$name' has no matching workflow job")
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# For each owning workflow, classify safe vs unsafe.
|
||||
for f in "${matches[@]}"; do
|
||||
rel="${f#"$REPO_ROOT"/}"
|
||||
# Heuristic: does the workflow have a top-level `paths:` filter?
|
||||
# Top-level here means under the `on:` key, not under jobs.<x>.if.
|
||||
# Workflow-level paths filters appear at indent depth 4 (under
|
||||
# `push:` or `pull_request:`). Job-level `if:` paths-filter doesn't
|
||||
# block the workflow from firing.
|
||||
has_top_paths=0
|
||||
if awk '
|
||||
# Track whether we are inside the `on:` block. The `on:` block
|
||||
# starts at column 0 (`on:` key) and ends when the next column-0
|
||||
# key appears.
|
||||
/^on:[[:space:]]*$/ { in_on = 1; next }
|
||||
/^[a-zA-Z]/ && in_on { in_on = 0 }
|
||||
in_on && /^[[:space:]]+paths:[[:space:]]*$/ { print "yes"; exit }
|
||||
in_on && /^[[:space:]]+paths:[[:space:]]*\[/ { print "yes"; exit }
|
||||
' "$f" | grep -q yes; then
|
||||
has_top_paths=1
|
||||
fi
|
||||
|
||||
if [[ "$has_top_paths" -eq 0 ]]; then
|
||||
# Safe: workflow always fires. If there are inner per-step if-
|
||||
# gates (single-job-with-per-step-if pattern), the no-op step
|
||||
# produces SUCCESS for the protected name — branch-protection-clean.
|
||||
continue
|
||||
fi
|
||||
|
||||
# Unsafe candidate — has top-level paths: AND we need to verify
|
||||
# the per-step if-gate pattern is absent. Look for any `if:`
|
||||
# referencing a paths-filter / detect-changes output inside the
|
||||
# owning job's body. If at least one is present, classify as the
|
||||
# single-job-with-per-step-if pattern (safe).
|
||||
#
|
||||
# The regex is intentionally anchored loosely — actual workflow
|
||||
# YAML writes per-step if-gates as ` - if: needs.X.outputs.Y`
|
||||
# (with the `-` step-marker between the leading spaces and the
|
||||
# `if`). Anchoring on `^[[:space:]]+if:` would miss those.
|
||||
if grep -qE "if:[[:space:]]+needs\.[a-zA-Z_-]+\.outputs\." "$f"; then
|
||||
# Per-step if-gates exist. Combined with top-level paths: this
|
||||
# would be a buggy mix (the workflow might still skip entirely
|
||||
# when paths exclude). Flag as unsafe — the safe pattern omits
|
||||
# the top-level paths: filter altogether and gates per-step.
|
||||
unsafe_findings+=("UNSAFE-MIX: $rel has top-level paths: AND per-step if-gates — when paths exclude the commit, the workflow doesn't fire and the required check '$name' is silently absent. Drop the top-level paths: filter; keep the per-step if-gates.")
|
||||
failed=1
|
||||
else
|
||||
# Top-level paths: with no per-step if-gates: the canonical
|
||||
# check-name parity bug.
|
||||
unsafe_findings+=("UNSAFE-PATH-FILTER: $rel has top-level paths: filter and no per-step if-gates. When paths exclude the commit, no job emits the required check '$name' — branch protection waits forever. Either drop the paths: filter and add per-step if-gates against a detect-changes output, or add an aggregator-with-needs+always() job that emits '$name'.")
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
done <<< "$required_names"
|
||||
|
||||
if [[ "$failed" -eq 0 ]]; then
|
||||
echo "check_name_parity: OK — every required check name maps to a safe workflow shape."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "check_name_parity: FOUND $((${#unsafe_findings[@]})) issue(s):" >&2
|
||||
for finding in "${unsafe_findings[@]}"; do
|
||||
echo " - $finding" >&2
|
||||
done
|
||||
exit 1
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/branch-protection/test_check_name_parity.sh — unit tests for
|
||||
# check_name_parity.sh.
|
||||
#
|
||||
# Builds synthetic apply.sh + workflow files in a tmpdir for each case,
|
||||
# invokes the script with REPO_ROOT pointing at the tmpdir, and asserts
|
||||
# on exit code + stderr. Per feedback_assert_exact_not_substring we
|
||||
# pin the EXACT exit code AND a substring of the stderr that names the
|
||||
# offending workflow + name combo — so a "false-pass that prints the
|
||||
# wrong message" still fails the test.
|
||||
#
|
||||
# Run locally: bash tools/branch-protection/test_check_name_parity.sh
|
||||
# Run in CI: same — added to ci.yml's shellcheck job's "E2E bash unit
|
||||
# tests" step alongside test_model_slug.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPT_UNDER_TEST="$SCRIPT_DIR/check_name_parity.sh"
|
||||
|
||||
if [[ ! -x "$SCRIPT_UNDER_TEST" ]]; then
|
||||
echo "test_check_name_parity: script under test missing or not executable: $SCRIPT_UNDER_TEST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
# Tracks the active tmpdir for the running case so the trap can clean
|
||||
# up even when assertions abort the case mid-flight.
|
||||
TMPDIR_FOR_CASE=""
|
||||
trap '[[ -n "$TMPDIR_FOR_CASE" && -d "$TMPDIR_FOR_CASE" ]] && rm -rf "$TMPDIR_FOR_CASE"' EXIT
|
||||
|
||||
# Build a synthetic repo at $1 with apply.sh listing $2 (one name per
|
||||
# line) as the staging required set + zero main required, then write
|
||||
# whatever .github/workflows/* files the test case adds.
|
||||
make_fake_repo() {
|
||||
local root="$1"
|
||||
local checks="$2"
|
||||
mkdir -p "$root/tools/branch-protection"
|
||||
mkdir -p "$root/.github/workflows"
|
||||
cat > "$root/tools/branch-protection/apply.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
# Stub apply.sh — only the heredoc-shaped check lists matter for the
|
||||
# parity script. Other functions intentionally absent.
|
||||
|
||||
read -r -d '' STAGING_CHECKS <<'EOF2' || true
|
||||
$checks
|
||||
EOF2
|
||||
|
||||
read -r -d '' MAIN_CHECKS <<'EOF2' || true
|
||||
$checks
|
||||
EOF2
|
||||
EOF
|
||||
chmod +x "$root/tools/branch-protection/apply.sh"
|
||||
# Place the script-under-test alongside its sibling apply.sh so the
|
||||
# script's REPO_ROOT walk finds the synthetic .github/workflows/.
|
||||
cp "$SCRIPT_UNDER_TEST" "$root/tools/branch-protection/check_name_parity.sh"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local desc="$1"
|
||||
local checks="$2"
|
||||
local workflow_yaml="$3" # contents to write
|
||||
local workflow_filename="$4"
|
||||
local expected_exit="$5"
|
||||
local expected_stderr_substring="$6"
|
||||
TMPDIR_FOR_CASE=$(mktemp -d)
|
||||
make_fake_repo "$TMPDIR_FOR_CASE" "$checks"
|
||||
printf '%s' "$workflow_yaml" > "$TMPDIR_FOR_CASE/.github/workflows/$workflow_filename"
|
||||
local stderr_file
|
||||
stderr_file=$(mktemp)
|
||||
local actual_exit=0
|
||||
bash "$TMPDIR_FOR_CASE/tools/branch-protection/check_name_parity.sh" 2>"$stderr_file" >/dev/null || actual_exit=$?
|
||||
local stderr_content
|
||||
stderr_content=$(cat "$stderr_file")
|
||||
rm "$stderr_file"
|
||||
if [[ "$actual_exit" -ne "$expected_exit" ]]; then
|
||||
echo "FAIL: $desc"
|
||||
echo " expected exit: $expected_exit, got: $actual_exit"
|
||||
echo " stderr: $stderr_content"
|
||||
FAILED=$((FAILED+1))
|
||||
rm -rf "$TMPDIR_FOR_CASE"; TMPDIR_FOR_CASE=""
|
||||
return
|
||||
fi
|
||||
# Empty expected substring → no assertion on stderr (used for the
|
||||
# passing case where stderr should be empty / not interesting).
|
||||
if [[ -n "$expected_stderr_substring" ]]; then
|
||||
if ! grep -qF "$expected_stderr_substring" <<< "$stderr_content"; then
|
||||
echo "FAIL: $desc"
|
||||
echo " expected stderr to contain: '$expected_stderr_substring'"
|
||||
echo " actual stderr: $stderr_content"
|
||||
FAILED=$((FAILED+1))
|
||||
rm -rf "$TMPDIR_FOR_CASE"; TMPDIR_FOR_CASE=""
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "PASS: $desc"
|
||||
PASSED=$((PASSED+1))
|
||||
rm -rf "$TMPDIR_FOR_CASE"; TMPDIR_FOR_CASE=""
|
||||
}
|
||||
|
||||
# Case 1: safe workflow — no top-level paths: filter, single job
|
||||
# emitting the required name. Should exit 0.
|
||||
run_case "safe: no paths filter, job emits required name" \
|
||||
"Foo Build" \
|
||||
"$(cat <<'EOF'
|
||||
name: Foo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
foo:
|
||||
name: Foo Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
EOF
|
||||
)" \
|
||||
"foo.yml" \
|
||||
0 \
|
||||
""
|
||||
|
||||
# Case 2: unsafe — top-level paths: filter AND no per-step if-gates.
|
||||
# This is the silent-block shape from the saved memory.
|
||||
run_case "unsafe: top-level paths: filter without per-step if-gates" \
|
||||
"Bar Build" \
|
||||
"$(cat <<'EOF'
|
||||
name: Bar
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'bar/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'bar/**'
|
||||
|
||||
jobs:
|
||||
bar:
|
||||
name: Bar Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
EOF
|
||||
)" \
|
||||
"bar.yml" \
|
||||
1 \
|
||||
"UNSAFE-PATH-FILTER"
|
||||
|
||||
# Case 3: required name has no emitter at all.
|
||||
run_case "missing: required name not in any workflow" \
|
||||
"Nonexistent Job" \
|
||||
"$(cat <<'EOF'
|
||||
name: Other
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
other:
|
||||
name: Other Job
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
EOF
|
||||
)" \
|
||||
"other.yml" \
|
||||
1 \
|
||||
"MISSING: required check name 'Nonexistent Job'"
|
||||
|
||||
# Case 4: safe — top-level paths: filter is absent BUT per-step if-
|
||||
# gates are present (single-job-with-per-step-if pattern, what
|
||||
# ci.yml + e2e-api.yml use). Should exit 0.
|
||||
run_case "safe: per-step if-gates without top-level paths" \
|
||||
"Baz Build" \
|
||||
"$(cat <<'EOF'
|
||||
name: Baz
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
baz: ${{ steps.check.outputs.baz }}
|
||||
steps:
|
||||
- id: check
|
||||
run: echo "baz=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
baz:
|
||||
needs: changes
|
||||
name: Baz Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: needs.changes.outputs.baz != 'true'
|
||||
run: echo no-op
|
||||
- if: needs.changes.outputs.baz == 'true'
|
||||
run: echo real work
|
||||
EOF
|
||||
)" \
|
||||
"baz.yml" \
|
||||
0 \
|
||||
""
|
||||
|
||||
# Case 5: unsafe-mix — top-level paths: AND per-step if-gates. The
|
||||
# script flags this distinctly because the workflow may STILL skip
|
||||
# entirely when paths exclude the commit (the per-step gates only
|
||||
# matter if the workflow actually fires).
|
||||
run_case "unsafe-mix: top-level paths: AND per-step if-gates" \
|
||||
"Qux Build" \
|
||||
"$(cat <<'EOF'
|
||||
name: Qux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'qux/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'qux/**'
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
qux: ${{ steps.check.outputs.qux }}
|
||||
steps:
|
||||
- id: check
|
||||
run: echo "qux=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
qux:
|
||||
needs: changes
|
||||
name: Qux Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: needs.changes.outputs.qux == 'true'
|
||||
run: echo build
|
||||
EOF
|
||||
)" \
|
||||
"qux.yml" \
|
||||
1 \
|
||||
"UNSAFE-MIX"
|
||||
|
||||
# Case 6: codeql.yml matrix — required names like "Analyze (go)" are
|
||||
# generated by `Analyze (${{ matrix.language }})`. Script must
|
||||
# special-case match this pattern.
|
||||
run_case "matrix: codeql Analyze (go) is recognised via matrix expansion" \
|
||||
"$(printf 'Analyze (go)\nAnalyze (javascript-typescript)\nAnalyze (python)')" \
|
||||
"$(cat <<'EOF'
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
language: [go, javascript-typescript, python]
|
||||
steps:
|
||||
- run: echo analyse
|
||||
EOF
|
||||
)" \
|
||||
"codeql.yml" \
|
||||
0 \
|
||||
""
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo "test_check_name_parity: $PASSED passed, $FAILED failed"
|
||||
echo "================================================"
|
||||
exit "$FAILED"
|
||||
+22
-21
@@ -1,19 +1,23 @@
|
||||
# Platform-only image (no canvas). Used by publish-platform-image workflow
|
||||
# for GHCR + Fly registry. Tenant image uses Dockerfile.tenant instead.
|
||||
# Platform-only image (no canvas). Used by publish-workspace-server-image
|
||||
# workflow for ECR. Tenant image uses Dockerfile.tenant instead.
|
||||
#
|
||||
# Build context: repo root.
|
||||
# Templates + plugins are pre-cloned by scripts/clone-manifest.sh (in CI
|
||||
# or on the operator host) into .tenant-bundle-deps/ — same pattern as
|
||||
# Dockerfile.tenant. See that file's header for the full rationale; the
|
||||
# short version is that post-2026-05-06 every workspace-template-* and
|
||||
# org-template-* repo on Gitea is private, so an in-image `git clone`
|
||||
# has no auth path that doesn't leak the Gitea token into a layer.
|
||||
#
|
||||
# Build context: repo root, with `.tenant-bundle-deps/` populated by the
|
||||
# workflow's "Pre-clone manifest deps" step (Task #173).
|
||||
|
||||
FROM golang:1.25-alpine AS builder
|
||||
WORKDIR /app
|
||||
# Plugin source for replace directive in go.mod
|
||||
COPY molecule-ai-plugin-github-app-auth/ /plugin/
|
||||
COPY workspace-server/go.mod workspace-server/go.sum ./
|
||||
# Add replace directives for Docker builds:
|
||||
# 1. Platform → plugin (plugin source at /plugin/)
|
||||
# 2. Plugin → platform (plugin's go.mod has a relative replace that doesn't
|
||||
# work in Docker; fix it to point at /app where the platform source lives)
|
||||
RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod
|
||||
RUN sed -i 's|replace github.com/Molecule-AI/molecule-monorepo/platform => .*|replace github.com/Molecule-AI/molecule-monorepo/platform => /app|' /plugin/go.mod
|
||||
# github-app-auth plugin removed 2026-05-07 (#157): per-agent Gitea
|
||||
# identities replaced the GitHub-App-installation token flow after the
|
||||
# 2026-05-06 suspension. Pre-removal this stage COPY'd the sibling
|
||||
# plugin repo + injected a `replace` directive; both are gone.
|
||||
RUN go mod download
|
||||
COPY workspace-server/ .
|
||||
# GIT_SHA mirror of Dockerfile.tenant — see that file for the rationale.
|
||||
@@ -30,21 +34,18 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags "-X github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo.GitSHA=${GIT_SHA}" \
|
||||
-o /memory-plugin ./cmd/memory-plugin-postgres
|
||||
|
||||
# Clone templates + plugins at build time from manifest.json
|
||||
FROM alpine:3.20 AS templates
|
||||
RUN apk add --no-cache git jq
|
||||
COPY manifest.json /manifest.json
|
||||
COPY scripts/clone-manifest.sh /scripts/clone-manifest.sh
|
||||
RUN chmod +x /scripts/clone-manifest.sh && /scripts/clone-manifest.sh /manifest.json /workspace-configs-templates /org-templates /plugins
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates git tzdata wget
|
||||
COPY --from=builder /platform /platform
|
||||
COPY --from=builder /memory-plugin /memory-plugin
|
||||
COPY workspace-server/migrations /migrations
|
||||
COPY --from=templates /workspace-configs-templates /workspace-configs-templates
|
||||
COPY --from=templates /org-templates /org-templates
|
||||
COPY --from=templates /plugins /plugins
|
||||
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
|
||||
# trusted CI / operator-host context, .git already stripped). The Gitea
|
||||
# token used to clone them never enters this image — same shape as
|
||||
# Dockerfile.tenant.
|
||||
COPY .tenant-bundle-deps/workspace-configs-templates /workspace-configs-templates
|
||||
COPY .tenant-bundle-deps/org-templates /org-templates
|
||||
COPY .tenant-bundle-deps/plugins /plugins
|
||||
# Non-root runtime with Docker socket access for workspace provisioning.
|
||||
RUN addgroup -g 1000 platform && adduser -u 1000 -G platform -s /bin/sh -D platform
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -3,22 +3,43 @@
|
||||
# Serves both the API (Go on :8080) and the UI (Node.js on :3000) in a
|
||||
# single container. Go reverse-proxies unknown routes to canvas.
|
||||
#
|
||||
# Templates are cloned from standalone GitHub repos at build time so the
|
||||
# monorepo doesn't need to carry them. The repos are public; no auth.
|
||||
# Templates + plugins are NOT cloned at build time. They are pre-cloned
|
||||
# in the trusted CI context (or operator host) by
|
||||
# `scripts/clone-manifest.sh` into `.tenant-bundle-deps/` and COPYed in.
|
||||
# The reason: post-2026-05-06, every workspace-template-* repo on Gitea
|
||||
# (codex, crewai, deepagents, gemini-cli, langgraph) plus all 7
|
||||
# org-template-* repos are private, so the Docker build can't `git clone`
|
||||
# from inside the build context — there's no auth path that doesn't leak
|
||||
# the Gitea token into an image layer. Pre-cloning keeps the token in
|
||||
# the CI environment only; the resulting image carries the cloned trees
|
||||
# with `.git` already stripped (see clone-manifest.sh).
|
||||
#
|
||||
# Build context: repo root.
|
||||
# Build context: repo root, with `.tenant-bundle-deps/` populated by:
|
||||
#
|
||||
# MOLECULE_GITEA_TOKEN=<persona-PAT> scripts/clone-manifest.sh \
|
||||
# manifest.json \
|
||||
# .tenant-bundle-deps/workspace-configs-templates \
|
||||
# .tenant-bundle-deps/org-templates \
|
||||
# .tenant-bundle-deps/plugins
|
||||
#
|
||||
# In CI this happens in publish-workspace-server-image.yml's "Pre-clone
|
||||
# manifest deps" step (uses AUTO_SYNC_TOKEN = devops-engineer persona).
|
||||
# For a manual operator-host build, source the same token from
|
||||
# /etc/molecule-bootstrap/agent-secrets.env first.
|
||||
#
|
||||
# docker buildx build --platform linux/amd64 \
|
||||
# -f workspace-server/Dockerfile.tenant \
|
||||
# -t registry.fly.io/molecule-tenant:latest \
|
||||
# -t <ECR>/molecule-ai/platform-tenant:latest \
|
||||
# --build-arg GIT_SHA=<sha> --build-arg NEXT_PUBLIC_PLATFORM_URL= \
|
||||
# --push .
|
||||
|
||||
# ── Stage 1: Go platform binary ──────────────────────────────────────
|
||||
FROM golang:1.25-alpine AS go-builder
|
||||
WORKDIR /app
|
||||
COPY molecule-ai-plugin-github-app-auth/ /plugin/
|
||||
COPY workspace-server/go.mod workspace-server/go.sum ./
|
||||
RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod
|
||||
# github-app-auth plugin removed 2026-05-07 (#157): per-agent Gitea
|
||||
# identities replaced GitHub-App tokens post-suspension. The sibling
|
||||
# COPY + replace directive are gone.
|
||||
RUN go mod download
|
||||
COPY workspace-server/ .
|
||||
|
||||
@@ -54,14 +75,7 @@ ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: Clone templates + plugins from manifest.json ─────────────
|
||||
FROM alpine:3.20 AS templates
|
||||
RUN apk add --no-cache git jq
|
||||
COPY manifest.json /manifest.json
|
||||
COPY scripts/clone-manifest.sh /scripts/clone-manifest.sh
|
||||
RUN chmod +x /scripts/clone-manifest.sh && /scripts/clone-manifest.sh /manifest.json /workspace-configs-templates /org-templates /plugins
|
||||
|
||||
# ── Stage 4: Runtime ──────────────────────────────────────────────────
|
||||
# ── Stage 3: Runtime ──────────────────────────────────────────────────
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache ca-certificates git tzdata openssh-client aws-cli
|
||||
|
||||
@@ -86,10 +100,13 @@ COPY --from=go-builder /platform /platform
|
||||
COPY --from=go-builder /memory-plugin /memory-plugin
|
||||
COPY workspace-server/migrations /migrations
|
||||
|
||||
# Templates + plugins (cloned from GitHub in stage 3)
|
||||
COPY --from=templates /workspace-configs-templates /workspace-configs-templates
|
||||
COPY --from=templates /org-templates /org-templates
|
||||
COPY --from=templates /plugins /plugins
|
||||
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
|
||||
# trusted CI / operator-host context, .git already stripped — see
|
||||
# .tenant-bundle-deps/ in the build context). The Gitea token used to
|
||||
# clone them never enters this image.
|
||||
COPY .tenant-bundle-deps/workspace-configs-templates /workspace-configs-templates
|
||||
COPY .tenant-bundle-deps/org-templates /org-templates
|
||||
COPY .tenant-bundle-deps/plugins /plugins
|
||||
|
||||
# Canvas standalone
|
||||
WORKDIR /canvas
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// verifyConfig is the typed dependency bundle for verifyParity.
|
||||
@@ -121,7 +122,7 @@ func verifyParity(ctx context.Context, cfg verifyConfig, stdout *os.File) (*veri
|
||||
matched := true
|
||||
for _, c := range legacy {
|
||||
if pluginContents[c] == 0 {
|
||||
fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, truncate(c, 80))
|
||||
fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, textutil.TruncateBytes(c, 80))
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
@@ -192,9 +193,4 @@ func queryLegacyMemories(ctx context.Context, db *sql.DB, workspaceID string) ([
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
|
||||
|
||||
@@ -349,16 +349,8 @@ func TestVerifyParity_PickSampleError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Truncate ---
|
||||
|
||||
func TestVerifyTruncate(t *testing.T) {
|
||||
if got := truncate("short", 10); got != "short" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := truncate(strings.Repeat("a", 200), 10); !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("expected ellipsis: %q", got)
|
||||
}
|
||||
}
|
||||
// Truncate moved to internal/textutil — coverage in
|
||||
// internal/textutil/truncate_test.go (TestTruncateBytes_RuneBoundary).
|
||||
|
||||
// --- CLI: -verify mode ---
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestResolveBindHost pins the precedence: BIND_ADDR explicit > dev-mode
|
||||
// fail-open default of 127.0.0.1 > production-shape empty (all interfaces).
|
||||
//
|
||||
// Mutation-test invariant: removing the IsDevModeFailOpen() branch makes
|
||||
// "no_bindaddr_devmode_unset_admin" fail (returns "" instead of "127.0.0.1").
|
||||
// Removing the BIND_ADDR branch makes "explicit_bindaddr_*" cases fail.
|
||||
func TestResolveBindHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
bindAddr string
|
||||
adminToken string
|
||||
molEnv string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no_bindaddr_devmode_unset_admin",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "dev",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_devmode_unset_admin_full_word",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "development",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_admin_set_in_dev_env",
|
||||
bindAddr: "",
|
||||
adminToken: "secret",
|
||||
molEnv: "dev",
|
||||
want: "", // ADMIN_TOKEN flips IsDevModeFailOpen to false → all interfaces
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_production_env",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "production",
|
||||
want: "", // production is not a dev value → all interfaces
|
||||
},
|
||||
{
|
||||
name: "no_bindaddr_unset_env",
|
||||
bindAddr: "",
|
||||
adminToken: "",
|
||||
molEnv: "",
|
||||
want: "", // unset MOLECULE_ENV → not dev → all interfaces
|
||||
},
|
||||
{
|
||||
name: "explicit_bindaddr_loopback_overrides_devmode",
|
||||
bindAddr: "127.0.0.1",
|
||||
adminToken: "",
|
||||
molEnv: "dev",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "explicit_bindaddr_wildcard_overrides_devmode_default",
|
||||
bindAddr: "0.0.0.0",
|
||||
adminToken: "",
|
||||
molEnv: "dev",
|
||||
want: "0.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "explicit_bindaddr_in_production",
|
||||
bindAddr: "10.0.5.7",
|
||||
adminToken: "secret",
|
||||
molEnv: "production",
|
||||
want: "10.0.5.7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("BIND_ADDR", tc.bindAddr)
|
||||
t.Setenv("ADMIN_TOKEN", tc.adminToken)
|
||||
t.Setenv("MOLECULE_ENV", tc.molEnv)
|
||||
got := resolveBindHost()
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveBindHost() = %q, want %q (BIND_ADDR=%q ADMIN_TOKEN=%q MOLECULE_ENV=%q)",
|
||||
got, tc.want, tc.bindAddr, tc.adminToken, tc.molEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/imagewatch"
|
||||
memwiring "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/wiring"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
|
||||
@@ -29,8 +30,7 @@ import (
|
||||
|
||||
// External plugins — each registers EnvMutator(s) that run at workspace
|
||||
// provision time. Loaded via soft-dep gates in main() so self-hosters
|
||||
// without the App or without per-agent identity configured keep working.
|
||||
githubappauth "github.com/Molecule-AI/molecule-ai-plugin-github-app-auth/pluginloader"
|
||||
// without per-agent identity configured keep working.
|
||||
ghidentity "github.com/Molecule-AI/molecule-ai-plugin-gh-identity/pluginloader"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
@@ -179,12 +179,15 @@ func main() {
|
||||
}
|
||||
|
||||
// External-plugin env mutators — each plugin contributes 0+ mutators
|
||||
// onto a shared registry. Order matters: gh-identity populates
|
||||
// MOLECULE_AGENT_ROLE-derived attribution env vars that downstream
|
||||
// mutators and the workspace's install.sh can then read. Keep
|
||||
// github-app-auth last because it fails loudly on misconfig and its
|
||||
// failure mode is "no GITHUB_TOKEN" — worth surfacing after the
|
||||
// cheaper mutators already ran.
|
||||
// onto a shared registry. gh-identity populates MOLECULE_AGENT_ROLE-
|
||||
// derived attribution env vars that the workspace's install.sh can
|
||||
// then read.
|
||||
//
|
||||
// github-app-auth was dropped 2026-05-07 (closes #157): per-agent
|
||||
// Gitea identities (this gh-identity plugin's role-derived path)
|
||||
// replaced GitHub-App-installation tokens after the 2026-05-06
|
||||
// suspension. Workspaces now provision with a per-persona Gitea PAT
|
||||
// from .env instead of an App-rotated GITHUB_TOKEN.
|
||||
envReg := provisionhook.NewRegistry()
|
||||
|
||||
// gh-identity plugin — per-agent attribution via env injection + gh
|
||||
@@ -198,26 +201,6 @@ func main() {
|
||||
log.Printf("gh-identity: registered (config file=%q)", os.Getenv("MOLECULE_GH_IDENTITY_CONFIG_FILE"))
|
||||
}
|
||||
|
||||
// github-app-auth plugin — injects GITHUB_TOKEN + GH_TOKEN into every
|
||||
// workspace env using the App's installation access token (rotates ~hourly).
|
||||
// Soft-skip when GITHUB_APP_* env vars are absent so dev/self-hosters
|
||||
// without an App configured keep working; fail-loud only on MISCONFIG
|
||||
// (e.g. APP_ID set but key file missing), not on unset.
|
||||
if os.Getenv("GITHUB_APP_ID") != "" {
|
||||
if reg, err := githubappauth.BuildRegistry(); err != nil {
|
||||
log.Fatalf("github-app-auth plugin: %v", err)
|
||||
} else {
|
||||
// Copy the plugin's mutators onto the shared registry so the
|
||||
// TokenProvider probe (FirstTokenProvider) still finds them.
|
||||
for _, m := range reg.Mutators() {
|
||||
envReg.Register(m)
|
||||
}
|
||||
log.Printf("github-app-auth: registered, %d mutator(s) added to chain", reg.Len())
|
||||
}
|
||||
} else {
|
||||
log.Println("github-app-auth: GITHUB_APP_ID unset — skipping plugin registration (agents will use any PAT from .env)")
|
||||
}
|
||||
|
||||
wh.SetEnvMutators(envReg)
|
||||
log.Printf("env-mutator chain: %v", envReg.Names())
|
||||
|
||||
@@ -337,15 +320,23 @@ func main() {
|
||||
// Router
|
||||
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr, memBundle)
|
||||
|
||||
// HTTP server with graceful shutdown
|
||||
// HTTP server with graceful shutdown.
|
||||
//
|
||||
// Bind host: in dev-mode (no ADMIN_TOKEN, MOLECULE_ENV=dev|development)
|
||||
// the AdminAuth chain fails open by design; pairing that with a wildcard
|
||||
// bind would expose unauth /workspaces to any same-LAN peer. Default to
|
||||
// loopback when fail-open is active. Operators who need LAN exposure set
|
||||
// BIND_ADDR=0.0.0.0 explicitly. Production (ADMIN_TOKEN set) is unchanged.
|
||||
// See molecule-core#7.
|
||||
bindHost := resolveBindHost()
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
Addr: fmt.Sprintf("%s:%s", bindHost, port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
log.Printf("Platform starting on :%s", port)
|
||||
log.Printf("Platform starting on %s:%s (dev-mode-fail-open=%v)", bindHost, port, middleware.IsDevModeFailOpen())
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
@@ -380,6 +371,29 @@ func envOr(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// resolveBindHost picks the listener interface for the HTTP server.
|
||||
//
|
||||
// Precedence:
|
||||
// 1. BIND_ADDR — explicit operator override (any value, including "0.0.0.0").
|
||||
// 2. dev-mode fail-open active → "127.0.0.1" (loopback only).
|
||||
// 3. otherwise → "" (Go binds every interface; existing prod/self-host shape).
|
||||
//
|
||||
// Coupling the loopback default to middleware.IsDevModeFailOpen() means the
|
||||
// two safety levers — bind narrowness and auth strength — move together. A
|
||||
// production deploy (ADMIN_TOKEN set) keeps binding to all interfaces because
|
||||
// the auth chain is doing its job; a dev Mac (no ADMIN_TOKEN, MOLECULE_ENV=dev)
|
||||
// is reachable only via loopback because the auth chain is fail-open. See
|
||||
// molecule-core#7 for the original LAN exposure finding.
|
||||
func resolveBindHost() string {
|
||||
if v := os.Getenv("BIND_ADDR"); v != "" {
|
||||
return v
|
||||
}
|
||||
if middleware.IsDevModeFailOpen() {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findConfigsDir() string {
|
||||
candidates := []string{
|
||||
"workspace-configs-templates",
|
||||
|
||||
@@ -5,7 +5,6 @@ go 1.25.0
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f
|
||||
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
|
||||
@@ -6,8 +6,6 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d h1:GpYhP6FxaJZc1Ljy5/YJ9ZIVGvfOqZBmDolNr2S5x2g=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d/go.mod h1:3a6LR/zd7FjR9ZwLTbytwYlWuCBsbCOVFlEg0WnoYiM=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
|
||||
@@ -51,7 +51,7 @@ func Import(
|
||||
return result
|
||||
}
|
||||
|
||||
_ = broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", wsID, map[string]interface{}{
|
||||
_ = broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), wsID, map[string]interface{}{
|
||||
"name": b.Name,
|
||||
"tier": b.Tier,
|
||||
"source_bundle_id": b.ID,
|
||||
@@ -142,7 +142,7 @@ func markFailed(ctx context.Context, wsID string, broadcaster *events.Broadcaste
|
||||
db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3`,
|
||||
models.StatusFailed, msg, wsID)
|
||||
broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", wsID, map[string]interface{}{
|
||||
broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), wsID, map[string]interface{}{
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -304,14 +305,14 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
|
||||
"parts": []map[string]interface{}{{"kind": "text", "text": msg.Text}},
|
||||
},
|
||||
"metadata": map[string]interface{}{
|
||||
"source": ch.ChannelType,
|
||||
"channel_id": ch.ID,
|
||||
"chat_id": msg.ChatID,
|
||||
"user_id": msg.UserID,
|
||||
"username": msg.Username,
|
||||
"message_id": msg.MessageID,
|
||||
"history": history,
|
||||
"extra": msg.Metadata,
|
||||
"source": ch.ChannelType,
|
||||
"channel_id": ch.ID,
|
||||
"chat_id": msg.ChatID,
|
||||
"user_id": msg.UserID,
|
||||
"username": msg.Username,
|
||||
"message_id": msg.MessageID,
|
||||
"history": history,
|
||||
"extra": msg.Metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -383,7 +384,7 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
|
||||
|
||||
// Broadcast event
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.RecordAndBroadcast(ctx, "CHANNEL_MESSAGE", ch.WorkspaceID, map[string]interface{}{
|
||||
m.broadcaster.RecordAndBroadcast(ctx, string(events.EventChannelMessage), ch.WorkspaceID, map[string]interface{}{
|
||||
"channel_id": ch.ID,
|
||||
"channel_type": ch.ChannelType,
|
||||
"username": msg.Username,
|
||||
@@ -427,7 +428,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
|
||||
}
|
||||
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster.RecordAndBroadcast(ctx, "CHANNEL_MESSAGE", ch.WorkspaceID, map[string]interface{}{
|
||||
m.broadcaster.RecordAndBroadcast(ctx, string(events.EventChannelMessage), ch.WorkspaceID, map[string]interface{}{
|
||||
"channel_id": ch.ID,
|
||||
"channel_type": ch.ChannelType,
|
||||
"direction": "outbound",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package events
|
||||
|
||||
// types.go — typed taxonomy of WebSocket event names emitted by the
|
||||
// workspace-server.
|
||||
//
|
||||
// RFC #2945 PR-B. Pre-consolidation, every BroadcastOnly /
|
||||
// RecordAndBroadcast call site passed a bare string literal:
|
||||
//
|
||||
// h.broadcaster.BroadcastOnly(workspaceID, "AGENT_MESSAGE", payload)
|
||||
//
|
||||
// Producers (Go workspace-server, ~30 call sites across handlers/,
|
||||
// scheduler/, registry/, bundle/) and consumers (canvas TS store +
|
||||
// component listeners) duplicated the same string with no shared
|
||||
// definition. A producer renaming an event silently broke every
|
||||
// consumer — same drift class that produced the reno-stars data-loss
|
||||
// regression on the persistence side. The fix on that side was the
|
||||
// AgentMessageWriter SSOT (PR-A); the fix on this side is named
|
||||
// constants.
|
||||
//
|
||||
// Why a typed string (not a plain enum / iota): the event name
|
||||
// crosses the wire to TypeScript consumers as the literal string in
|
||||
// `WSMessage.Event`. Iota integers would break the canvas store's
|
||||
// switch (`case "AGENT_MESSAGE":`); a typed string preserves the
|
||||
// wire contract while giving Go callers compile-time discipline.
|
||||
//
|
||||
// Mirror in canvas: a parity gate (PR-B-2 follow-up) will assert this
|
||||
// constant set ≡ the TypeScript union members in
|
||||
// `canvas/src/lib/ws-events.ts`. Today the canvas consumes the names
|
||||
// via bare-string comparisons; the mirror lands separately to keep
|
||||
// PR-B narrow.
|
||||
|
||||
// EventType is the wire-typed name of a WebSocket event the platform
|
||||
// broadcasts. Always emit constants from this file rather than bare
|
||||
// strings — the AST gate in events_types_drift_test.go guards
|
||||
// against bare-string usage in the broadcaster surfaces.
|
||||
type EventType string
|
||||
|
||||
// Event constants — the canonical taxonomy. New events MUST be added
|
||||
// here AND mirrored in canvas/src/lib/ws-events.ts (parity gate
|
||||
// pending in PR-B-2). Group by semantic family so the list stays
|
||||
// scan-friendly as it grows.
|
||||
const (
|
||||
// Chat / agent messaging — surfaces in canvas chat panels.
|
||||
EventAgentMessage EventType = "AGENT_MESSAGE"
|
||||
EventA2AResponse EventType = "A2A_RESPONSE"
|
||||
EventActivityLogged EventType = "ACTIVITY_LOGGED"
|
||||
EventChannelMessage EventType = "CHANNEL_MESSAGE"
|
||||
|
||||
// Workspace lifecycle.
|
||||
EventWorkspaceProvisioning EventType = "WORKSPACE_PROVISIONING"
|
||||
EventWorkspaceProvisionFailed EventType = "WORKSPACE_PROVISION_FAILED"
|
||||
EventWorkspaceOnline EventType = "WORKSPACE_ONLINE"
|
||||
EventWorkspaceOffline EventType = "WORKSPACE_OFFLINE"
|
||||
EventWorkspaceDegraded EventType = "WORKSPACE_DEGRADED"
|
||||
EventWorkspaceHibernated EventType = "WORKSPACE_HIBERNATED"
|
||||
EventWorkspacePaused EventType = "WORKSPACE_PAUSED"
|
||||
EventWorkspaceRemoved EventType = "WORKSPACE_REMOVED"
|
||||
EventWorkspaceAwaitingAgent EventType = "WORKSPACE_AWAITING_AGENT"
|
||||
EventWorkspaceHeartbeat EventType = "WORKSPACE_HEARTBEAT"
|
||||
|
||||
// Agent assignment + identity.
|
||||
EventAgentAssigned EventType = "AGENT_ASSIGNED"
|
||||
EventAgentReplaced EventType = "AGENT_REPLACED"
|
||||
EventAgentRemoved EventType = "AGENT_REMOVED"
|
||||
EventAgentMoved EventType = "AGENT_MOVED"
|
||||
EventAgentCardUpdated EventType = "AGENT_CARD_UPDATED"
|
||||
|
||||
// Delegation lifecycle.
|
||||
EventDelegationSent EventType = "DELEGATION_SENT"
|
||||
EventDelegationStatus EventType = "DELEGATION_STATUS"
|
||||
EventDelegationComplete EventType = "DELEGATION_COMPLETE"
|
||||
EventDelegationFailed EventType = "DELEGATION_FAILED"
|
||||
|
||||
// Task progression + scheduler.
|
||||
EventTaskUpdated EventType = "TASK_UPDATED"
|
||||
EventCronExecuted EventType = "CRON_EXECUTED"
|
||||
EventCronSkipped EventType = "CRON_SKIPPED"
|
||||
|
||||
// Approvals.
|
||||
EventApprovalRequested EventType = "APPROVAL_REQUESTED"
|
||||
EventApprovalEscalated EventType = "APPROVAL_ESCALATED"
|
||||
|
||||
// Auth / credentials.
|
||||
EventExternalCredentialsRotated EventType = "EXTERNAL_CREDENTIALS_ROTATED"
|
||||
)
|
||||
|
||||
// AllEventTypes lists every constant in this file. Used by the
|
||||
// snapshot test (events_types_drift_test.go) to detect when a new
|
||||
// constant is added without updating the snapshot — the catch-up
|
||||
// step is mirroring the addition into canvas/src/lib/ws-events.ts so
|
||||
// canvas consumers can switch on it.
|
||||
//
|
||||
// Keep in lexicographic order so the snapshot diff is stable on
|
||||
// renames and the parity-with-TS comparison is order-independent.
|
||||
var AllEventTypes = []EventType{
|
||||
EventA2AResponse,
|
||||
EventActivityLogged,
|
||||
EventAgentAssigned,
|
||||
EventAgentCardUpdated,
|
||||
EventAgentMessage,
|
||||
EventAgentMoved,
|
||||
EventAgentRemoved,
|
||||
EventAgentReplaced,
|
||||
EventApprovalEscalated,
|
||||
EventApprovalRequested,
|
||||
EventChannelMessage,
|
||||
EventCronExecuted,
|
||||
EventCronSkipped,
|
||||
EventDelegationComplete,
|
||||
EventDelegationFailed,
|
||||
EventDelegationSent,
|
||||
EventDelegationStatus,
|
||||
EventExternalCredentialsRotated,
|
||||
EventTaskUpdated,
|
||||
EventWorkspaceAwaitingAgent,
|
||||
EventWorkspaceDegraded,
|
||||
EventWorkspaceHeartbeat,
|
||||
EventWorkspaceHibernated,
|
||||
EventWorkspaceOffline,
|
||||
EventWorkspaceOnline,
|
||||
EventWorkspacePaused,
|
||||
EventWorkspaceProvisionFailed,
|
||||
EventWorkspaceProvisioning,
|
||||
EventWorkspaceRemoved,
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAllEventTypes_IsSnapshot pins the canonical event taxonomy.
|
||||
// Adding a new constant in types.go without updating AllEventTypes
|
||||
// (or vice versa) fails this test.
|
||||
//
|
||||
// The snapshot is also the authoritative input to the canvas-side
|
||||
// parity gate (PR-B-2 follow-up): the TypeScript union members in
|
||||
// canvas/src/lib/ws-events.ts MUST match this list exactly. A drift
|
||||
// gate at CI time will assert set equality once the TS file lands.
|
||||
func TestAllEventTypes_IsSnapshot(t *testing.T) {
|
||||
// Every named constant must appear in AllEventTypes. Walk via
|
||||
// reflection over the package-level vars would over-include test
|
||||
// fixtures, so list the canonical names here. When a constant
|
||||
// is added in types.go, append the EventType's literal value
|
||||
// to the expected list below — the failure message names
|
||||
// exactly what's missing so the diff is one-line obvious.
|
||||
expected := []string{
|
||||
"A2A_RESPONSE",
|
||||
"ACTIVITY_LOGGED",
|
||||
"AGENT_ASSIGNED",
|
||||
"AGENT_CARD_UPDATED",
|
||||
"AGENT_MESSAGE",
|
||||
"AGENT_MOVED",
|
||||
"AGENT_REMOVED",
|
||||
"AGENT_REPLACED",
|
||||
"APPROVAL_ESCALATED",
|
||||
"APPROVAL_REQUESTED",
|
||||
"CHANNEL_MESSAGE",
|
||||
"CRON_EXECUTED",
|
||||
"CRON_SKIPPED",
|
||||
"DELEGATION_COMPLETE",
|
||||
"DELEGATION_FAILED",
|
||||
"DELEGATION_SENT",
|
||||
"DELEGATION_STATUS",
|
||||
"EXTERNAL_CREDENTIALS_ROTATED",
|
||||
"TASK_UPDATED",
|
||||
"WORKSPACE_AWAITING_AGENT",
|
||||
"WORKSPACE_DEGRADED",
|
||||
"WORKSPACE_HEARTBEAT",
|
||||
"WORKSPACE_HIBERNATED",
|
||||
"WORKSPACE_OFFLINE",
|
||||
"WORKSPACE_ONLINE",
|
||||
"WORKSPACE_PAUSED",
|
||||
"WORKSPACE_PROVISIONING",
|
||||
"WORKSPACE_PROVISION_FAILED",
|
||||
"WORKSPACE_REMOVED",
|
||||
}
|
||||
sort.Strings(expected)
|
||||
|
||||
actual := make([]string, 0, len(AllEventTypes))
|
||||
for _, e := range AllEventTypes {
|
||||
actual = append(actual, string(e))
|
||||
}
|
||||
sort.Strings(actual)
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf("AllEventTypes count = %d, want %d\nactual: %s\nexpected: %s",
|
||||
len(actual), len(expected),
|
||||
strings.Join(actual, ", "),
|
||||
strings.Join(expected, ", "))
|
||||
return
|
||||
}
|
||||
for i, want := range expected {
|
||||
if actual[i] != want {
|
||||
t.Errorf("AllEventTypes[%d] = %q, want %q (full diff:\n actual: %v\n expected: %v\n)",
|
||||
i, actual[i], want, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventType_NoEmptyConstants pins that no constant declared in
|
||||
// types.go has an accidentally-empty value. The catch is the
|
||||
// "WORKSPACE_X" → forgot-to-fill pattern: a typo in the literal
|
||||
// would surface as the empty string, and broadcast pipelines would
|
||||
// silently filter empty-name events without any error signal.
|
||||
func TestEventType_NoEmptyConstants(t *testing.T) {
|
||||
for _, e := range AllEventTypes {
|
||||
if string(e) == "" {
|
||||
t.Errorf("found empty EventType in AllEventTypes — typo in types.go?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventType_AllUppercaseSnakeCase pins the wire format. Mixed
|
||||
// case or kebab-case would break the canvas TypeScript switch
|
||||
// statements (every consumer's `case "AGENT_MESSAGE":` is upper-
|
||||
// snake). The check is the catch for an accidental
|
||||
// `"agent_message"` typo that wouldn't fail the snapshot gate.
|
||||
func TestEventType_AllUppercaseSnakeCase(t *testing.T) {
|
||||
for _, e := range AllEventTypes {
|
||||
s := string(e)
|
||||
// Allowed chars: A-Z, 0-9, _ — nothing else, no leading/
|
||||
// trailing underscores, no consecutive underscores.
|
||||
if s != strings.ToUpper(s) {
|
||||
t.Errorf("EventType %q is not all-uppercase — wire format requires upper-snake", s)
|
||||
}
|
||||
if strings.HasPrefix(s, "_") || strings.HasSuffix(s, "_") {
|
||||
t.Errorf("EventType %q has leading/trailing underscore — disallowed", s)
|
||||
}
|
||||
if strings.Contains(s, "__") {
|
||||
t.Errorf("EventType %q has consecutive underscores — disallowed", s)
|
||||
}
|
||||
for _, r := range s {
|
||||
if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
|
||||
t.Errorf("EventType %q contains disallowed char %q", s, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,11 +413,56 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
return http.StatusOK, respBody, nil
|
||||
}
|
||||
|
||||
// Mock-runtime short-circuit. Workspaces with runtime='mock' have
|
||||
// no container, no EC2, no URL — every reply is synthesised here
|
||||
// from a small canned-variant pool. Built for the "200-workspace
|
||||
// mock org" demo: a CEO/VPs/Managers/ICs hierarchy that renders
|
||||
// at scale on the canvas without burning real LLM credits or
|
||||
// provisioning 200 EC2 instances. See mock_runtime.go for the
|
||||
// full rationale + reply shape contract.
|
||||
//
|
||||
// Position: AFTER poll-mode (mock isn't a delivery mode, it's a
|
||||
// runtime; treating poll-set-on-mock as poll matches operator
|
||||
// intent if anyone ever does that), BEFORE resolveAgentURL (mock
|
||||
// has no URL — going through resolveAgentURL would 404 on the
|
||||
// SELECT url since the row is provisioned as NULL).
|
||||
if status, respBody, handled := h.handleMockA2A(ctx, workspaceID, callerID, body, a2aMethod, logActivity); handled {
|
||||
return status, respBody, nil
|
||||
}
|
||||
|
||||
agentURL, proxyErr := h.resolveAgentURL(ctx, workspaceID)
|
||||
if proxyErr != nil {
|
||||
return 0, nil, proxyErr
|
||||
}
|
||||
|
||||
// Pre-flight container-health check (#36). The dispatchA2A path below
|
||||
// does Docker-DNS forwarding to `ws-<wsShort>:8000` and only catches a
|
||||
// missing/dead container REACTIVELY via maybeMarkContainerDead in
|
||||
// handleA2ADispatchError. That works but costs the caller a full
|
||||
// network-timeout (2-30s) before the structured 503 surfaces.
|
||||
//
|
||||
// When we KNOW the workspace is container-backed (h.docker != nil + we
|
||||
// rewrite to Docker-DNS form below), do a single proactive
|
||||
// RunningContainerName lookup. If the container is genuinely missing,
|
||||
// short-circuit with the same structured 503 + async restart that
|
||||
// maybeMarkContainerDead would produce — but immediately, without the
|
||||
// network round-trip.
|
||||
//
|
||||
// Three outcomes of provisioner.RunningContainerName(ctx, h.docker, id):
|
||||
// ("ws-<id>", nil) → forward as today.
|
||||
// ("", nil) → container is genuinely not running. Fast-503.
|
||||
// ("", err) → transient daemon error. Fall through to optimistic
|
||||
// forward — matches Provisioner.IsRunning's
|
||||
// (true, err) "fail-soft as alive" contract.
|
||||
//
|
||||
// Same SSOT as findRunningContainer (#10/#12). See AST gate
|
||||
// TestProxyA2A_RoutesThroughProvisionerSSOT.
|
||||
if h.provisioner != nil && platformInDocker && strings.HasPrefix(agentURL, "http://"+provisioner.ContainerName(workspaceID)+":") {
|
||||
if proxyErr := h.preflightContainerHealth(ctx, workspaceID); proxyErr != nil {
|
||||
return 0, nil, proxyErr
|
||||
}
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, cancelFwd, err := h.dispatchA2A(ctx, workspaceID, agentURL, body, callerID)
|
||||
if cancelFwd != nil {
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// proxyDispatchBuildError is a sentinel wrapper for failures inside
|
||||
// http.NewRequestWithContext. handleA2ADispatchError unwraps it to emit the
|
||||
// "failed to create proxy request" 500 instead of the standard 502/503 paths.
|
||||
@@ -90,10 +92,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
Response: gin.H{
|
||||
"error": "workspace agent busy — adapter handles retry (native_session)",
|
||||
"busy": true,
|
||||
"retry_after": busyRetryAfterSeconds,
|
||||
"native_session": true,
|
||||
"error": "workspace agent busy — adapter handles retry (native_session)",
|
||||
"busy": true,
|
||||
"retry_after": busyRetryAfterSeconds,
|
||||
"native_session": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -149,7 +151,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// Provisioner selection (mutually exclusive in production):
|
||||
// - h.provisioner != nil → local Docker deployment; IsRunning does docker inspect.
|
||||
// - h.cpProv != nil → SaaS / EC2 deployment; IsRunning calls CP's
|
||||
// /cp/workspaces/:id/status to read the EC2 state.
|
||||
// /cp/workspaces/:id/status to read the EC2 state.
|
||||
//
|
||||
// Pre-fix this function ONLY consulted h.provisioner — for SaaS tenants
|
||||
// (h.provisioner=nil, h.cpProv=set) it short-circuited to false on every
|
||||
@@ -191,11 +193,65 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
log.Printf("ProxyA2A: failed to mark workspace %s offline: %v", workspaceID, err)
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_OFFLINE", workspaceID, map[string]interface{}{})
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
go h.RestartByID(workspaceID)
|
||||
return true
|
||||
}
|
||||
|
||||
// preflightContainerHealth runs a proactive Provisioner.IsRunning check
|
||||
// (#36) before dispatching the a2a forward. Routed through provisioner's
|
||||
// SSOT IsRunning, which itself wraps RunningContainerName — same source
|
||||
// as findRunningContainer in the plugins handler (#10/#12).
|
||||
//
|
||||
// Returns nil when the forward should proceed:
|
||||
// - container is running, OR
|
||||
// - daemon errored transiently (matches IsRunning's (true, err)
|
||||
// "fail-soft as alive" contract — let the optimistic forward run
|
||||
// and reactive maybeMarkContainerDead catch a real failure).
|
||||
//
|
||||
// Returns a structured 503 + triggers the same async restart that
|
||||
// maybeMarkContainerDead would produce, when:
|
||||
// - container is genuinely not running (NotFound / Exited / Created…).
|
||||
//
|
||||
// The point of running this BEFORE the forward is to save the caller
|
||||
// 2-30s of network-timeout cost when the container is missing — a common
|
||||
// shape post-EC2-replace (see molecule-controlplane#20 incident
|
||||
// 2026-05-07) where the reconciler hasn't respawned the agent yet.
|
||||
func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspaceID string) *proxyA2AError {
|
||||
running, err := h.provisioner.IsRunning(ctx, workspaceID)
|
||||
if err != nil {
|
||||
// Transient daemon error. Provisioner.IsRunning returns (true, err)
|
||||
// in this case — fall through to the optimistic forward, reactive
|
||||
// maybeMarkContainerDead handles a real failure later.
|
||||
log.Printf("ProxyA2A preflight: IsRunning transient error for %s: %v (proceeding with forward)", workspaceID, err)
|
||||
return nil
|
||||
}
|
||||
if running {
|
||||
// Container is running — forward as today.
|
||||
return nil
|
||||
}
|
||||
// Container is genuinely not running. Mark offline + trigger restart
|
||||
// (same effect as maybeMarkContainerDead's branch), and return the
|
||||
// structured 503 immediately so the caller skips the forward.
|
||||
log.Printf("ProxyA2A preflight: container for %s is not running — marking offline and triggering restart (#36)", workspaceID)
|
||||
if _, dbErr := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status NOT IN ('removed', 'provisioning')`,
|
||||
models.StatusOffline, workspaceID); dbErr != nil {
|
||||
log.Printf("ProxyA2A preflight: failed to mark workspace %s offline: %v", workspaceID, dbErr)
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
go h.RestartByID(workspaceID)
|
||||
return &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{
|
||||
"error": "workspace container not running — restart triggered",
|
||||
"restarting": true,
|
||||
"preflight": true, // distinguishes from reactive containerDead path
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// logA2AFailure records a failed A2A attempt to activity_logs in a detached
|
||||
// goroutine (the request context may already be done by the time it runs).
|
||||
func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int) {
|
||||
@@ -272,7 +328,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
}(ctx)
|
||||
|
||||
if callerID == "" && statusCode < 400 {
|
||||
h.broadcaster.BroadcastOnly(workspaceID, "A2A_RESPONSE", map[string]interface{}{
|
||||
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
|
||||
"response_body": json.RawMessage(respBody),
|
||||
"method": a2aMethod,
|
||||
"duration_ms": durationMs,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
)
|
||||
|
||||
// preflightLocalProv is a controllable LocalProvisionerAPI stub for the
|
||||
// preflight tests (#36). Other API methods panic to guard against tests
|
||||
// that should be using a different stub.
|
||||
type preflightLocalProv struct {
|
||||
running bool
|
||||
err error
|
||||
calls int
|
||||
calledWith []string
|
||||
}
|
||||
|
||||
func (p *preflightLocalProv) IsRunning(_ context.Context, workspaceID string) (bool, error) {
|
||||
p.calls++
|
||||
p.calledWith = append(p.calledWith, workspaceID)
|
||||
return p.running, p.err
|
||||
}
|
||||
func (p *preflightLocalProv) Start(_ context.Context, _ provisioner.WorkspaceConfig) (string, error) {
|
||||
panic("preflightLocalProv: Start not implemented")
|
||||
}
|
||||
func (p *preflightLocalProv) Stop(_ context.Context, _ string) error {
|
||||
panic("preflightLocalProv: Stop not implemented")
|
||||
}
|
||||
func (p *preflightLocalProv) ExecRead(_ context.Context, _, _ string) ([]byte, error) {
|
||||
panic("preflightLocalProv: ExecRead not implemented")
|
||||
}
|
||||
func (p *preflightLocalProv) RemoveVolume(_ context.Context, _ string) error {
|
||||
panic("preflightLocalProv: RemoveVolume not implemented")
|
||||
}
|
||||
func (p *preflightLocalProv) VolumeHasFile(_ context.Context, _, _ string) (bool, error) {
|
||||
panic("preflightLocalProv: VolumeHasFile not implemented")
|
||||
}
|
||||
func (p *preflightLocalProv) WriteAuthTokenToVolume(_ context.Context, _, _ string) error {
|
||||
panic("preflightLocalProv: WriteAuthTokenToVolume not implemented")
|
||||
}
|
||||
|
||||
// TestPreflight_ContainerRunning_ReturnsNil — IsRunning(true,nil): forward
|
||||
// proceeds. preflight returns nil → caller continues to dispatchA2A.
|
||||
func TestPreflight_ContainerRunning_ReturnsNil(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
stub := &preflightLocalProv{running: true, err: nil}
|
||||
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
h.provisioner = stub
|
||||
|
||||
if err := h.preflightContainerHealth(context.Background(), "ws-running-123"); err != nil {
|
||||
t.Fatalf("preflight should return nil when container running, got %+v", err)
|
||||
}
|
||||
if stub.calls != 1 {
|
||||
t.Errorf("IsRunning should be called exactly once, got %d", stub.calls)
|
||||
}
|
||||
if len(stub.calledWith) != 1 || stub.calledWith[0] != "ws-running-123" {
|
||||
t.Errorf("IsRunning should be called with workspace id, got %v", stub.calledWith)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflight_ContainerNotRunning_StructuredFastFail — IsRunning(false,nil):
|
||||
// preflight returns structured 503 with restarting=true + preflight=true, AND
|
||||
// triggers the offline-flip + WORKSPACE_OFFLINE broadcast + async restart.
|
||||
// This is the load-bearing case — saves the caller 2-30s of network timeout.
|
||||
func TestPreflight_ContainerNotRunning_StructuredFastFail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
_ = setupTestRedis(t)
|
||||
stub := &preflightLocalProv{running: false, err: nil}
|
||||
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
h.provisioner = stub
|
||||
|
||||
// Expect the offline-flip UPDATE.
|
||||
mock.ExpectExec(`UPDATE workspaces SET status =`).
|
||||
WithArgs(models.StatusOffline, "ws-dead-456").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Broadcaster's INSERT INTO structure_events fires too — best-effort
|
||||
// log entry for the WORKSPACE_OFFLINE event. Match permissively.
|
||||
mock.ExpectExec(`INSERT INTO structure_events`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
proxyErr := h.preflightContainerHealth(context.Background(), "ws-dead-456")
|
||||
if proxyErr == nil {
|
||||
t.Fatal("preflight should return *proxyA2AError when container not running")
|
||||
}
|
||||
if proxyErr.Status != 503 {
|
||||
t.Errorf("expected 503, got %d", proxyErr.Status)
|
||||
}
|
||||
if got := proxyErr.Response["restarting"]; got != true {
|
||||
t.Errorf("response should mark restarting=true, got %v", got)
|
||||
}
|
||||
if got := proxyErr.Response["preflight"]; got != true {
|
||||
t.Errorf("response should mark preflight=true so callers can distinguish from reactive containerDead, got %v", got)
|
||||
}
|
||||
if got := proxyErr.Response["error"]; got != "workspace container not running — restart triggered" {
|
||||
t.Errorf("error message mismatch, got %q", got)
|
||||
}
|
||||
|
||||
// Note: broadcaster firing is exercised by the production path's
|
||||
// h.broadcaster.RecordAndBroadcast call but not asserted here — the
|
||||
// real *events.Broadcaster doesn't expose received events for inspection.
|
||||
// The DB UPDATE expectation is sufficient to pin the offline-flip path.
|
||||
}
|
||||
|
||||
// TestPreflight_TransientError_FailsSoftAsAlive — IsRunning(true,err): the
|
||||
// (true, err) "fail-soft" contract — preflight returns nil so the optimistic
|
||||
// forward runs; reactive maybeMarkContainerDead handles a real failure later.
|
||||
// This pin is critical: a flaky daemon must NOT trigger a restart cascade.
|
||||
func TestPreflight_TransientError_FailsSoftAsAlive(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
stub := &preflightLocalProv{running: true, err: errors.New("docker daemon EOF")}
|
||||
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
h.provisioner = stub
|
||||
|
||||
if err := h.preflightContainerHealth(context.Background(), "ws-flaky-789"); err != nil {
|
||||
t.Fatalf("preflight should return nil on transient error (fail-soft), got %+v", err)
|
||||
}
|
||||
// No DB UPDATE expected — sqlmock would complain about unexpected calls
|
||||
// at test cleanup if the offline-flip path fired.
|
||||
}
|
||||
|
||||
// TestProxyA2A_Preflight_RoutesThroughProvisionerSSOT — AST gate (#36 mirror
|
||||
// of #12's gate). Pins the invariant that preflightContainerHealth uses the
|
||||
// SSOT Provisioner.IsRunning helper, NOT a parallel docker.ContainerInspect
|
||||
// of its own.
|
||||
//
|
||||
// Mutation invariant: if a future PR replaces h.provisioner.IsRunning with
|
||||
// a direct cli.ContainerInspect call, this test fails. That's the signal to
|
||||
// either (a) extend Provisioner.IsRunning's contract OR (b) document why
|
||||
// this call site needs to differ. Either way, the drift gets a reviewer's
|
||||
// attention instead of shipping silently.
|
||||
func TestProxyA2A_Preflight_RoutesThroughProvisionerSSOT(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "a2a_proxy_helpers.go", nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse a2a_proxy_helpers.go: %v", err)
|
||||
}
|
||||
|
||||
var fn *ast.FuncDecl
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
f, ok := n.(*ast.FuncDecl)
|
||||
if !ok || f.Name.Name != "preflightContainerHealth" {
|
||||
return true
|
||||
}
|
||||
fn = f
|
||||
return false
|
||||
})
|
||||
if fn == nil {
|
||||
t.Fatal("preflightContainerHealth not found — was it renamed? update this gate or the SSOT routing assumption")
|
||||
}
|
||||
|
||||
var (
|
||||
callsIsRunning bool
|
||||
callsContainerInspectRaw bool
|
||||
callsRunningContainerNameDirect bool
|
||||
)
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch sel.Sel.Name {
|
||||
case "IsRunning":
|
||||
callsIsRunning = true
|
||||
case "ContainerInspect":
|
||||
callsContainerInspectRaw = true
|
||||
case "RunningContainerName":
|
||||
// Direct RunningContainerName is also acceptable SSOT — but
|
||||
// preferring IsRunning keeps the (bool, error) contract that
|
||||
// already exists in the helper API surface.
|
||||
callsRunningContainerNameDirect = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if !callsIsRunning && !callsRunningContainerNameDirect {
|
||||
t.Errorf("preflightContainerHealth must call provisioner.IsRunning OR provisioner.RunningContainerName for the SSOT health check — see molecule-core#36. Found neither.")
|
||||
}
|
||||
if callsContainerInspectRaw {
|
||||
t.Errorf("preflightContainerHealth carries a direct ContainerInspect call. This is the parallel-impl drift molecule-core#36 fixed. " +
|
||||
"Either route through provisioner.IsRunning OR — if a new use case truly needs a different inspect — extend the helper's contract first and update this gate to allow the specific delta.")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// extractIdempotencyKey pulls params.message.messageId out of an A2A JSON-RPC
|
||||
@@ -419,7 +421,7 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
|
||||
AND method = 'delegate_result'
|
||||
AND target_id = $4
|
||||
AND response_body->>'delegation_id' = $5
|
||||
`, "Delegation completed ("+truncate(responseText, 80)+")", string(respJSON),
|
||||
`, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON),
|
||||
sourceID, targetID, delegationID)
|
||||
if err != nil {
|
||||
log.Printf("A2AQueue drain stitch: update failed for delegation %s: %v", delegationID, err)
|
||||
@@ -435,10 +437,10 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
|
||||
// "⏸ queued" line to "✓ completed" in real time. Without this the
|
||||
// transition only surfaces after the user reloads or polls activity.
|
||||
if h.broadcaster != nil {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"target_id": targetID,
|
||||
"response_preview": truncate(responseText, 200),
|
||||
"response_preview": textutil.TruncateBytes(responseText, 200),
|
||||
"via": "queue_drain",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
|
||||
func (h *ActivityHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
activityType := c.Query("type")
|
||||
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
|
||||
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
|
||||
peerID := c.Query("peer_id") // optional UUID — restrict to rows where this peer is sender OR target
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
sinceSecsStr := c.Query("since_secs")
|
||||
@@ -465,78 +465,30 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify workspace exists
|
||||
var wsName string
|
||||
err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`, workspaceID,
|
||||
).Scan(&wsName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
// Single source of truth for chat-bearing agent → user messages —
|
||||
// see agent_message_writer.go for the contract. Pre-RFC-#2945, the
|
||||
// broadcast + INSERT pair was inlined here and again in
|
||||
// mcp_tools.go's send_message_to_user, and the duplication is what
|
||||
// produced the reno-stars data-loss regression. Both paths now
|
||||
// route through the same writer; future channels (Slack, Discord,
|
||||
// Lark) hook in here too.
|
||||
attachments := make([]AgentMessageAttachment, 0, len(body.Attachments))
|
||||
for _, a := range body.Attachments {
|
||||
attachments = append(attachments, AgentMessageAttachment{
|
||||
URI: a.URI,
|
||||
Name: a.Name,
|
||||
MimeType: a.MimeType,
|
||||
Size: a.Size,
|
||||
})
|
||||
}
|
||||
|
||||
broadcastPayload := map[string]interface{}{
|
||||
"message": body.Message,
|
||||
"workspace_id": workspaceID,
|
||||
"name": wsName,
|
||||
}
|
||||
if len(body.Attachments) > 0 {
|
||||
broadcastPayload["attachments"] = body.Attachments
|
||||
}
|
||||
h.broadcaster.BroadcastOnly(workspaceID, "AGENT_MESSAGE", broadcastPayload)
|
||||
|
||||
// Persist to activity_logs so the chat history loader restores this
|
||||
// message after a page reload. Pre-fix, send_message_to_user pushes
|
||||
// were broadcast-only — survived the WebSocket session but vanished
|
||||
// when the user refreshed because nothing wrote them to the DB.
|
||||
//
|
||||
// Shape chosen to match the existing loader query
|
||||
// (`type=a2a_receive&source=canvas`):
|
||||
// - activity_type='a2a_receive' so it joins the same query path
|
||||
// - source_id=NULL so the canvas-source filter accepts it
|
||||
// - method='notify' to distinguish from real A2A receives in audits
|
||||
// - request_body=NULL so the loader doesn't append a duplicate
|
||||
// "user message" bubble for it
|
||||
// - response_body={"result": "<text>"} matches extractResponseText's
|
||||
// simplest branch ({result: string} → take verbatim)
|
||||
//
|
||||
// Errors are logged-only — broadcast already succeeded, the user
|
||||
// sees the message; persistence failure just means the message
|
||||
// won't survive reload (pre-fix behavior). Don't fail the whole
|
||||
// notify on a DB hiccup.
|
||||
// response_body shape — chosen to feed BOTH:
|
||||
// - extractResponseText: looks at body.result (string) and returns it
|
||||
// - extractFilesFromTask: looks at body.parts[] for kind=file
|
||||
// so a chat reload after a notify-with-attachments restores both
|
||||
// the text bubble AND the download chips.
|
||||
respPayload := map[string]interface{}{"result": body.Message}
|
||||
if len(body.Attachments) > 0 {
|
||||
fileParts := make([]map[string]interface{}, 0, len(body.Attachments))
|
||||
for _, a := range body.Attachments {
|
||||
fileMeta := map[string]interface{}{"uri": a.URI, "name": a.Name}
|
||||
if a.MimeType != "" {
|
||||
fileMeta["mimeType"] = a.MimeType
|
||||
}
|
||||
if a.Size > 0 {
|
||||
fileMeta["size"] = a.Size
|
||||
}
|
||||
fileParts = append(fileParts, map[string]interface{}{
|
||||
"kind": "file",
|
||||
"file": fileMeta,
|
||||
})
|
||||
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"})
|
||||
return
|
||||
}
|
||||
respPayload["parts"] = fileParts
|
||||
}
|
||||
respJSON, _ := json.Marshal(respPayload)
|
||||
preview := body.Message
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:80] + "…"
|
||||
}
|
||||
if _, err := db.DB.ExecContext(c.Request.Context(), `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, summary, response_body, status)
|
||||
VALUES ($1, 'a2a_receive', 'notify', $2, $3::jsonb, 'ok')
|
||||
`, workspaceID, "Agent message: "+preview, string(respJSON)); err != nil {
|
||||
log.Printf("Notify: failed to persist message for %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
@@ -628,7 +580,45 @@ func (h *ActivityHandler) Report(c *gin.Context) {
|
||||
// LogActivity inserts an activity log and optionally broadcasts via WebSocket.
|
||||
// Takes events.EventEmitter (#1814) so callers passing a stub broadcaster
|
||||
// in tests no longer need to construct the full *events.Broadcaster.
|
||||
//
|
||||
// Errors are logged and swallowed — this is the fire-and-forget contract
|
||||
// most callers expect. For atomic-with-sibling-writes use LogActivityTx
|
||||
// and propagate the error.
|
||||
func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params ActivityParams) {
|
||||
hook, err := logActivityExec(ctx, db.DB, broadcaster, params)
|
||||
if err != nil {
|
||||
log.Printf("LogActivity insert error: %v", err)
|
||||
return
|
||||
}
|
||||
hook()
|
||||
}
|
||||
|
||||
// LogActivityTx inserts the activity row inside the caller-provided tx
|
||||
// and returns a commitHook that fires the post-commit ACTIVITY_LOGGED
|
||||
// broadcast. Caller MUST invoke commitHook AFTER tx.Commit() — firing
|
||||
// it before commit can leak a WebSocket event for a row that ends up
|
||||
// rolled back, which the canvas's optimistic UI then shows then loses.
|
||||
//
|
||||
// Returns an error if the INSERT fails — caller should Rollback. Caller
|
||||
// is also responsible for tx.BeginTx + tx.Commit/Rollback. Used by
|
||||
// chat_files uploadPollMode so PutBatchTx + N activity rows commit
|
||||
// atomically; if any activity row fails, the pending_uploads rows roll
|
||||
// back too and the client retries the entire multipart upload cleanly.
|
||||
func LogActivityTx(ctx context.Context, tx *sql.Tx, broadcaster events.EventEmitter, params ActivityParams) (commitHook func(), err error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("LogActivityTx: tx is nil")
|
||||
}
|
||||
return logActivityExec(ctx, tx, broadcaster, params)
|
||||
}
|
||||
|
||||
// activityExecutor is the SQL surface LogActivity[Tx] needs. *sql.Tx
|
||||
// and *sql.DB both satisfy it, so the same insert path serves the
|
||||
// fire-and-forget caller (db.DB) and the Tx-aware caller (*sql.Tx).
|
||||
type activityExecutor interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster events.EventEmitter, params ActivityParams) (commitHook func(), err error) {
|
||||
reqJSON, reqErr := json.Marshal(params.RequestBody)
|
||||
if reqErr != nil {
|
||||
log.Printf("LogActivity: failed to marshal request_body for %s: %v", params.WorkspaceID, reqErr)
|
||||
@@ -654,20 +644,21 @@ func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params Ac
|
||||
traceStr = &s
|
||||
}
|
||||
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, tool_trace, duration_ms, status, error_detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb, $10, $11, $12)
|
||||
`, params.WorkspaceID, params.ActivityType, params.SourceID, params.TargetID,
|
||||
params.Method, params.Summary, reqStr, respStr, traceStr,
|
||||
params.DurationMs, params.Status, params.ErrorDetail)
|
||||
if err != nil {
|
||||
log.Printf("LogActivity insert error: %v", err)
|
||||
return
|
||||
params.DurationMs, params.Status, params.ErrorDetail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Broadcast ACTIVITY_LOGGED event
|
||||
// Build the broadcast payload up-front so the post-commit hook is a
|
||||
// pure in-memory call — no JSON marshaling between commit and emit
|
||||
// where a panic would leak the row without an event.
|
||||
var payload map[string]interface{}
|
||||
if broadcaster != nil {
|
||||
payload := map[string]interface{}{
|
||||
payload = map[string]interface{}{
|
||||
"activity_type": params.ActivityType,
|
||||
"method": params.Method,
|
||||
"summary": params.Summary,
|
||||
@@ -698,8 +689,13 @@ func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params Ac
|
||||
if respStr != nil {
|
||||
payload["response_body"] = json.RawMessage(respJSON)
|
||||
}
|
||||
broadcaster.BroadcastOnly(params.WorkspaceID, "ACTIVITY_LOGGED", payload)
|
||||
}
|
||||
|
||||
return func() {
|
||||
if broadcaster != nil {
|
||||
broadcaster.BroadcastOnly(params.WorkspaceID, string(events.EventActivityLogged), payload)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ActivityParams struct {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -909,6 +910,114 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
|
||||
// contract: LogActivityTx returns a commitHook that the caller MUST
|
||||
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
|
||||
// LogActivityTx itself. Firing inside would leak a websocket event
|
||||
// for a row that the caller may roll back, painting a ghost message
|
||||
// into the canvas's optimistic UI that disappears on the next refresh.
|
||||
func TestLogActivityTx_DefersBroadcastUntilCommitHook(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
tx, err := db.DB.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
method := "chat_upload_receive"
|
||||
hook, err := LogActivityTx(context.Background(), tx, cb, ActivityParams{
|
||||
WorkspaceID: "ws-123",
|
||||
ActivityType: "a2a_receive",
|
||||
Method: &method,
|
||||
Status: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LogActivityTx: %v", err)
|
||||
}
|
||||
if len(cb.calls) != 0 {
|
||||
t.Errorf("broadcast leaked before commitHook: got %d calls", len(cb.calls))
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
hook()
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("commitHook must broadcast exactly once, got %d", len(cb.calls))
|
||||
}
|
||||
if cb.calls[0].eventType != "ACTIVITY_LOGGED" {
|
||||
t.Errorf("event type = %q, want ACTIVITY_LOGGED", cb.calls[0].eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_InsertError_NoHook_NoBroadcast — when the INSERT
|
||||
// fails inside the Tx, LogActivityTx returns an error and a nil
|
||||
// commitHook. The caller is expected to Rollback; no broadcast can
|
||||
// possibly fire because the hook never exists.
|
||||
func TestLogActivityTx_InsertError_NoHook_NoBroadcast(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(errors.New("constraint violation simulated"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
tx, err := db.DB.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
method := "chat_upload_receive"
|
||||
hook, err := LogActivityTx(context.Background(), tx, cb, ActivityParams{
|
||||
WorkspaceID: "ws-123",
|
||||
ActivityType: "a2a_receive",
|
||||
Method: &method,
|
||||
Status: "ok",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on INSERT failure, got nil")
|
||||
}
|
||||
if hook != nil {
|
||||
t.Errorf("commitHook must be nil on insert error, got non-nil hook")
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if len(cb.calls) != 0 {
|
||||
t.Errorf("broadcast must NOT fire on insert error, got %d calls", len(cb.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_NilTx_Errors — passing a nil tx is caller misuse.
|
||||
// Return an error rather than panicking on the nil receiver inside
|
||||
// ExecContext (which would crash the request goroutine and surface as
|
||||
// a 500 with no log line tying it to the bad call site).
|
||||
func TestLogActivityTx_NilTx_Errors(t *testing.T) {
|
||||
cb := &recordingBroadcaster{}
|
||||
hook, err := LogActivityTx(context.Background(), nil, cb, ActivityParams{
|
||||
WorkspaceID: "ws-123",
|
||||
ActivityType: "a2a_receive",
|
||||
Status: "ok",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("nil tx must error, got nil")
|
||||
}
|
||||
if hook != nil {
|
||||
t.Errorf("commitHook must be nil when tx is nil, got non-nil hook")
|
||||
}
|
||||
if len(cb.calls) != 0 {
|
||||
t.Errorf("broadcast must NOT fire on nil-tx error, got %d", len(cb.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogActivity_Broadcast_IncludesResponseBody(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
@@ -56,10 +56,17 @@ type RefreshResult struct {
|
||||
Recreated []string `json:"recreated"`
|
||||
}
|
||||
|
||||
// TemplateImageRef returns the canonical GHCR ref for a runtime's template
|
||||
// image. Single source of truth shared with imagewatch.
|
||||
// TemplateImageRef returns the canonical image ref for a runtime's template,
|
||||
// using the configured registry (provisioner.RegistryPrefix()) and the
|
||||
// moving `:latest` tag. Single source of truth shared with imagewatch.
|
||||
//
|
||||
// Defaults to ghcr.io/molecule-ai/workspace-template-<runtime>:latest
|
||||
// (upstream OSS). When MOLECULE_IMAGE_REGISTRY is set in the environment
|
||||
// (typically the AWS ECR mirror in production), this returns the prefixed
|
||||
// equivalent so admin operations and image-watch checks hit the same
|
||||
// registry the provisioner pulls from.
|
||||
func TemplateImageRef(runtime string) string {
|
||||
return fmt.Sprintf("ghcr.io/molecule-ai/workspace-template-%s:latest", runtime)
|
||||
return fmt.Sprintf("%s/workspace-template-%s:latest", provisioner.RegistryPrefix(), runtime)
|
||||
}
|
||||
|
||||
// ghcrAuthHeader returns the base64-encoded JSON auth payload Docker's
|
||||
|
||||
@@ -69,7 +69,7 @@ func (h *AgentHandler) Assign(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_ASSIGNED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentAssigned), workspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": body.Model,
|
||||
})
|
||||
@@ -118,7 +118,7 @@ func (h *AgentHandler) Replace(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_REPLACED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentReplaced), workspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": body.Model,
|
||||
"old_model": oldModel,
|
||||
@@ -148,7 +148,7 @@ func (h *AgentHandler) Remove(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_REMOVED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentRemoved), workspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
})
|
||||
@@ -215,21 +215,21 @@ func (h *AgentHandler) Move(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Broadcast on both workspaces
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_MOVED", sourceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"target_workspace_id": body.TargetWorkspaceID,
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentMoved), sourceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"target_workspace_id": body.TargetWorkspaceID,
|
||||
})
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_MOVED", body.TargetWorkspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"source_workspace_id": sourceID,
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentMoved), body.TargetWorkspaceID, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"source_workspace_id": sourceID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"from_workspace": sourceID,
|
||||
"to_workspace": body.TargetWorkspaceID,
|
||||
"agent_id": agentID,
|
||||
"model": model,
|
||||
"from_workspace": sourceID,
|
||||
"to_workspace": body.TargetWorkspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package handlers
|
||||
|
||||
// AgentMessageWriter is the SSOT for "agent → user" message delivery in the
|
||||
// workspace-server. Every chat-bearing path that surfaces a message to the
|
||||
// canvas — HTTP /notify (Notify handler), MCP tools/call
|
||||
// send_message_to_user (toolSendMessageToUser), any future channel — MUST
|
||||
// route through this writer rather than re-implement the broadcast +
|
||||
// persist contract inline.
|
||||
//
|
||||
// Why: pre-consolidation, two handlers duplicated the same "broadcast then
|
||||
// INSERT activity_logs" sequence. The reno-stars production data-loss
|
||||
// incident (2026-05-05, RFC #2945, PR #2944) was the symptom — the
|
||||
// persistence half landed for /notify but lagged for the MCP bridge by
|
||||
// months, silently dropping every long-form external-agent message until
|
||||
// reload. The AST gate from #2944 catches drift; this writer eliminates
|
||||
// the *possibility* of drift by giving both call sites a single
|
||||
// well-tested function to call.
|
||||
//
|
||||
// Contract:
|
||||
// 1. Look up the workspace by id; ErrWorkspaceNotFound on miss so the
|
||||
// caller can return 404 with a clean message.
|
||||
// 2. Broadcast a WS AGENT_MESSAGE event with {message, workspace_id,
|
||||
// name, attachments?}.
|
||||
// 3. INSERT a row into activity_logs:
|
||||
// type='a2a_receive', method='notify', source_id NULL,
|
||||
// response_body={"result": message[, "parts": [file kind...]]},
|
||||
// status='ok'
|
||||
// Best-effort — INSERT failure logs only, returns nil so the broadcast
|
||||
// success isn't undone on the caller side.
|
||||
// 4. Returns nil on success.
|
||||
//
|
||||
// The shape (especially the JSON response_body) is the wire contract the
|
||||
// canvas's chat-history hydrator (canvas/src/.../historyHydration.ts)
|
||||
// reads. Drift here silently breaks chat replay across all consumers, so
|
||||
// changes to the JSON shape MUST be cross-verified against the hydrator
|
||||
// in the same PR.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
// ErrWorkspaceNotFound is returned by AgentMessageWriter.Send when the
|
||||
// workspace lookup turns up nothing (or the workspace is in
|
||||
// status='removed'). Callers translate to HTTP 404 / JSON-RPC error /
|
||||
// whatever surface they expose. Real DB errors (connection drop, query
|
||||
// timeout) surface as wrapped errors and should be treated as 503.
|
||||
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
|
||||
|
||||
// AgentMessageAttachment is one file attached to an agent → user
|
||||
// message. Identical to handlers.NotifyAttachment in field set; kept
|
||||
// distinct so the writer's API doesn't import a handler type with HTTP
|
||||
// binding tags.
|
||||
type AgentMessageAttachment struct {
|
||||
URI string
|
||||
Name string
|
||||
MimeType string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// AgentMessageWriter persists + broadcasts agent → user messages. Construct
|
||||
// once per process via NewAgentMessageWriter; pass the same instance to
|
||||
// every handler that delivers chat (Notify, toolSendMessageToUser, etc.).
|
||||
//
|
||||
// Takes events.EventEmitter (not the *Broadcaster concrete type) so tests
|
||||
// can substitute a fake emitter and producers in other packages can wrap
|
||||
// the real broadcaster behind their own metrics / retries without leaking
|
||||
// the concrete dependency.
|
||||
type AgentMessageWriter struct {
|
||||
db *sql.DB
|
||||
broadcaster events.EventEmitter
|
||||
}
|
||||
|
||||
// NewAgentMessageWriter binds the writer to the platform's DB pool +
|
||||
// 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 +
|
||||
// persist in that order; ErrWorkspaceNotFound short-circuits before any
|
||||
// broadcast or DB write so callers can 404 cleanly.
|
||||
//
|
||||
// Returns nil on success — including on DB-INSERT failure (the broadcast
|
||||
// already returned successfully and the user has seen the message; the
|
||||
// persistence-failure mode is logged at WARN but the caller's response
|
||||
// stays 200 so the agent doesn't retry and double-broadcast).
|
||||
func (w *AgentMessageWriter) Send(
|
||||
ctx context.Context,
|
||||
workspaceID, message string,
|
||||
attachments []AgentMessageAttachment,
|
||||
) error {
|
||||
// 1. Workspace lookup. status='removed' filter is the same shape /notify
|
||||
// used pre-consolidation; deleted workspaces don't get notifications.
|
||||
//
|
||||
// Distinguish sql.ErrNoRows ("workspace genuinely not present" — caller
|
||||
// should 404) from real DB errors (connection drop, statement timeout,
|
||||
// pool exhaustion — caller should 503). Pre-fix this branch returned
|
||||
// ErrWorkspaceNotFound for any error, so during a DB outage every
|
||||
// notify call surfaced as "workspace not found" and masked real
|
||||
// incidents in the alert path.
|
||||
var wsName string
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`,
|
||||
workspaceID,
|
||||
).Scan(&wsName)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrWorkspaceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent_message: workspace lookup: %w", err)
|
||||
}
|
||||
|
||||
// 2. Build broadcast payload + WS-emit. Same shape that ChatTab's
|
||||
// AGENT_MESSAGE handler in canvas/src/store/canvas-events.ts has
|
||||
// consumed since the canvas chat shipped — drift here would orphan
|
||||
// every live chat panel.
|
||||
broadcastPayload := map[string]interface{}{
|
||||
"message": message,
|
||||
"workspace_id": workspaceID,
|
||||
"name": wsName,
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
broadcastPayload["attachments"] = attachments
|
||||
}
|
||||
w.broadcaster.BroadcastOnly(workspaceID, string(events.EventAgentMessage), broadcastPayload)
|
||||
|
||||
// 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
|
||||
// - extractFilesFromTask reads body.parts[] (kind=file) → renders chips
|
||||
respPayload := map[string]interface{}{"result": message}
|
||||
if len(attachments) > 0 {
|
||||
fileParts := make([]map[string]interface{}, 0, len(attachments))
|
||||
for _, a := range attachments {
|
||||
fileMeta := map[string]interface{}{"uri": a.URI, "name": a.Name}
|
||||
if a.MimeType != "" {
|
||||
fileMeta["mimeType"] = a.MimeType
|
||||
}
|
||||
if a.Size > 0 {
|
||||
fileMeta["size"] = a.Size
|
||||
}
|
||||
fileParts = append(fileParts, map[string]interface{}{
|
||||
"kind": "file",
|
||||
"file": fileMeta,
|
||||
})
|
||||
}
|
||||
respPayload["parts"] = fileParts
|
||||
}
|
||||
respJSON, _ := json.Marshal(respPayload)
|
||||
preview := textutil.TruncateRunes(message, 80)
|
||||
if _, err := w.db.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, summary, response_body, status)
|
||||
VALUES ($1, 'a2a_receive', 'notify', $2, $3::jsonb, 'ok')
|
||||
`, workspaceID, "Agent message: "+preview, string(respJSON)); err != nil {
|
||||
// Best-effort: the broadcast already returned ok and the user
|
||||
// has seen the message. Logging a structured line lets operators
|
||||
// notice persistence-failure rates spike if the DB is unhealthy,
|
||||
// without breaking the tool response or causing the agent to
|
||||
// retry-and-double-broadcast.
|
||||
log.Printf("agent_message: failed to persist for %s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
)
|
||||
|
||||
// AgentMessageWriter is the SSOT for agent → user chat delivery
|
||||
// (RFC #2945 PR-A). These tests pin the contract the writer
|
||||
// guarantees: workspace lookup, broadcast, INSERT, error semantics —
|
||||
// every shape that producers (Notify, toolSendMessageToUser, future
|
||||
// channels) rely on.
|
||||
//
|
||||
// Pre-consolidation, the broadcast-then-INSERT logic was duplicated
|
||||
// across two handlers and they drifted (reno-stars, 2026-05-05). With
|
||||
// the writer being the only place this logic lives, these tests are
|
||||
// the regression line for every chat-bearing path simultaneously.
|
||||
|
||||
// jsonMatcher is a sqlmock Argument matcher that decodes the actual
|
||||
// SQL arg as JSON and runs a caller-supplied predicate over the
|
||||
// resulting structure. Tighter than substring matching (which can
|
||||
// false-pass on a renamed key) and tolerant of map-key ordering
|
||||
// (which exact-string matching is not).
|
||||
type jsonMatcher struct {
|
||||
predicate func(parsed map[string]any) bool
|
||||
desc string
|
||||
}
|
||||
|
||||
func (m jsonMatcher) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||||
return false
|
||||
}
|
||||
return m.predicate(parsed)
|
||||
}
|
||||
|
||||
// stringMatcher pins exact prefix/suffix/equality checks against a
|
||||
// driver.Value that's actually a string.
|
||||
type stringMatcher func(string) bool
|
||||
|
||||
func (f stringMatcher) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return f(s)
|
||||
}
|
||||
|
||||
// capturingEmitter records every BroadcastOnly call so tests can pin
|
||||
// the WS event shape without a real ws.Hub. RecordAndBroadcast is
|
||||
// also captured for completeness — the writer doesn't call it today,
|
||||
// but a future producer might, and a captured-but-unasserted record
|
||||
// is easier to diagnose than a nil panic.
|
||||
type capturingEmitter struct {
|
||||
events []capturedEvent
|
||||
}
|
||||
|
||||
type capturedEvent struct {
|
||||
workspaceID string
|
||||
eventType string
|
||||
payload interface{}
|
||||
}
|
||||
|
||||
func (c *capturingEmitter) BroadcastOnly(workspaceID string, eventType string, payload interface{}) {
|
||||
c.events = append(c.events, capturedEvent{workspaceID, eventType, payload})
|
||||
}
|
||||
|
||||
func (c *capturingEmitter) RecordAndBroadcast(_ context.Context, eventType string, workspaceID string, payload interface{}) error {
|
||||
c.events = append(c.events, capturedEvent{workspaceID, eventType, payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_Success_NoAttachments pins the happy
|
||||
// path: workspace lookup, broadcast, INSERT, return nil.
|
||||
func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||
WithArgs(
|
||||
"ws-1",
|
||||
sqlmock.AnyArg(), // summary
|
||||
`{"result":"hi"}`,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
if err := w.Send(context.Background(), "ws-1", "hi", nil); err != nil {
|
||||
t.Fatalf("Send returned %v, want nil", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_Success_WithAttachments pins the file
|
||||
// attachment shape — response_body MUST contain a parts[] array with
|
||||
// kind=file entries so the canvas hydrater renders download chips.
|
||||
// Drift here = chips disappear on chat reload.
|
||||
func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-att").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||
WithArgs(
|
||||
"ws-att",
|
||||
sqlmock.AnyArg(),
|
||||
jsonMatcher{
|
||||
desc: "response_body has result + parts with kind=file metadata",
|
||||
predicate: func(p map[string]any) bool {
|
||||
if p["result"] != "see attached" {
|
||||
return false
|
||||
}
|
||||
parts, ok := p["parts"].([]any)
|
||||
if !ok || len(parts) != 1 {
|
||||
return false
|
||||
}
|
||||
part, ok := parts[0].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if part["kind"] != "file" {
|
||||
return false
|
||||
}
|
||||
file, ok := part["file"].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return file["uri"] == "workspace://x.zip" &&
|
||||
file["name"] == "x.zip" &&
|
||||
file["mimeType"] == "application/zip" &&
|
||||
file["size"].(float64) == 1234
|
||||
},
|
||||
},
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
atts := []AgentMessageAttachment{
|
||||
{URI: "workspace://x.zip", Name: "x.zip", MimeType: "application/zip", Size: 1234},
|
||||
}
|
||||
if err := w.Send(context.Background(), "ws-att", "see attached", atts); err != nil {
|
||||
t.Fatalf("Send returned %v, want nil", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_WorkspaceNotFound pins ErrWorkspaceNotFound
|
||||
// short-circuit. Must NOT broadcast, MUST NOT INSERT — caller will 404
|
||||
// or surface a JSON-RPC error.
|
||||
func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}))
|
||||
|
||||
err := w.Send(context.Background(), "ws-missing", "lost in the void", nil)
|
||||
if !errors.Is(err, ErrWorkspaceNotFound) {
|
||||
t.Errorf("Send returned %v, want ErrWorkspaceNotFound", err)
|
||||
}
|
||||
if len(emitter.events) != 0 {
|
||||
t.Errorf("workspace-not-found path MUST NOT broadcast, got %d events", len(emitter.events))
|
||||
}
|
||||
// Implicit: no INSERT expectation registered, so a stray INSERT
|
||||
// would fail ExpectationsWereMet.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations (INSERT must NOT fire on workspace-not-found): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil pins the
|
||||
// "best-effort persistence" contract: when the activity_log INSERT
|
||||
// fails (DB hiccup, transient connection, constraint), the writer
|
||||
// MUST still return nil. The broadcast already succeeded; the user
|
||||
// has seen the message; returning an error here would cause the
|
||||
// caller (and the agent calling the tool) to retry and double-
|
||||
// broadcast.
|
||||
func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-dbfail").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnError(errors.New("transient db error"))
|
||||
|
||||
err := w.Send(context.Background(), "ws-dbfail", "should not be lost from live chat", nil)
|
||||
if err != nil {
|
||||
t.Errorf("DB INSERT failure must return nil (broadcast already succeeded), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_PreviewTruncation pins the summary
|
||||
// preview cap. Long messages (Ryan's onboarding-friction report was
|
||||
// ~2k chars) must summarise to ≤80 chars + ellipsis so the activity
|
||||
// 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())
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-trunc").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
|
||||
|
||||
longMsg := strings.Repeat("x", 200)
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-trunc",
|
||||
stringMatcher(func(s string) bool {
|
||||
if !strings.HasPrefix(s, "Agent message: ") {
|
||||
return false
|
||||
}
|
||||
preview := strings.TrimPrefix(s, "Agent message: ")
|
||||
if !strings.HasSuffix(preview, "…") {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSuffix(preview, "…")
|
||||
return len(body) == 80
|
||||
}),
|
||||
sqlmock.AnyArg(),
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
if err := w.Send(context.Background(), "ws-trunc", longMsg, nil); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("preview truncation drift: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent pins the
|
||||
// WS event name + payload shape. The canvas's
|
||||
// canvas-events.ts:AGENT_MESSAGE handler reads {message, workspace_id,
|
||||
// name, attachments?} — drift here orphans every live chat panel.
|
||||
func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-bc").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Workspace Name"))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
atts := []AgentMessageAttachment{
|
||||
{URI: "workspace://a.txt", Name: "a.txt"},
|
||||
}
|
||||
if err := w.Send(context.Background(), "ws-bc", "hi", atts); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
if len(emitter.events) != 1 {
|
||||
t.Fatalf("expected exactly 1 broadcast, got %d", len(emitter.events))
|
||||
}
|
||||
ev := emitter.events[0]
|
||||
if ev.eventType != "AGENT_MESSAGE" {
|
||||
t.Errorf("event type = %q, want AGENT_MESSAGE", ev.eventType)
|
||||
}
|
||||
if ev.workspaceID != "ws-bc" {
|
||||
t.Errorf("workspace_id = %q, want ws-bc", ev.workspaceID)
|
||||
}
|
||||
pl, ok := ev.payload.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("payload not a map: %T", ev.payload)
|
||||
}
|
||||
if pl["message"] != "hi" {
|
||||
t.Errorf("payload.message = %v, want hi", pl["message"])
|
||||
}
|
||||
if pl["workspace_id"] != "ws-bc" {
|
||||
t.Errorf("payload.workspace_id = %v, want ws-bc", pl["workspace_id"])
|
||||
}
|
||||
if pl["name"] != "Workspace Name" {
|
||||
t.Errorf("payload.name = %v, want Workspace Name", pl["name"])
|
||||
}
|
||||
if pl["attachments"] == nil {
|
||||
t.Error("payload.attachments missing on attachment-bearing send")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped pins the
|
||||
// distinction between sql.ErrNoRows (legit not-found → 404) and real
|
||||
// DB errors (connection drop → 503). Pre-followup the lookup branch
|
||||
// returned ErrWorkspaceNotFound for ANY error, so during a DB outage
|
||||
// every notify call surfaced as "workspace not found" and masked
|
||||
// real incidents in alerting.
|
||||
func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
transientErr := errors.New("connection refused")
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-dbdown").
|
||||
WillReturnError(transientErr)
|
||||
|
||||
err := w.Send(context.Background(), "ws-dbdown", "hi", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected wrapped DB error, got nil")
|
||||
}
|
||||
if errors.Is(err, ErrWorkspaceNotFound) {
|
||||
t.Errorf("DB outage MUST NOT surface as ErrWorkspaceNotFound (masks incidents in alerting); got %v", err)
|
||||
}
|
||||
if !errors.Is(err, transientErr) {
|
||||
t.Errorf("expected wrapped %v, got %v", transientErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper-level truncate tests now live in
|
||||
// internal/textutil/truncate_test.go (TestTruncateRunes). The
|
||||
// integration-level coverage that exercises the agent_message_writer
|
||||
// path with non-ASCII content is TestAgentMessageWriter_Send_NonASCIIMessagePersists
|
||||
// below.
|
||||
|
||||
// TestAgentMessageWriter_Send_NonASCIIMessagePersists pins the end-to-end
|
||||
// path for non-ASCII messages — the original reno-stars regression
|
||||
// surfaced via byte-slice truncation breaking JSONB INSERT. Every
|
||||
// handler-level test had ASCII content, so this branch had no
|
||||
// coverage. Now it does.
|
||||
func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
// 200-rune CJK message — exceeds the 80-rune cap, would have hit
|
||||
// the byte-slice bug.
|
||||
msg := strings.Repeat("你", 200)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-cjk").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-cjk",
|
||||
stringMatcher(func(s string) bool {
|
||||
if !strings.HasPrefix(s, "Agent message: ") {
|
||||
return false
|
||||
}
|
||||
preview := strings.TrimPrefix(s, "Agent message: ")
|
||||
if !strings.HasSuffix(preview, "…") {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSuffix(preview, "…")
|
||||
// 80 runes of 你 = 80 codepoints. Each is 3 bytes UTF-8.
|
||||
if utf8.RuneCountInString(body) != 80 {
|
||||
return false
|
||||
}
|
||||
// MUST be valid UTF-8 — pre-fix byte-slice would have
|
||||
// returned half a codepoint here.
|
||||
return utf8.ValidString(body)
|
||||
}),
|
||||
sqlmock.AnyArg(),
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
if err := w.Send(context.Background(), "ws-cjk", msg, nil); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("non-ASCII path drift: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty pins the
|
||||
// "no key when nil" wire contract — extra empty fields would force
|
||||
// canvas consumers to defensively check for [] vs undefined; the
|
||||
// existing AGENT_MESSAGE handler treats absence as "no attachments".
|
||||
func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||
WithArgs("ws-noatt").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("X"))
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
if err := w.Send(context.Background(), "ws-noatt", "plain text", nil); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if len(emitter.events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(emitter.events))
|
||||
}
|
||||
pl := emitter.events[0].payload.(map[string]interface{})
|
||||
if _, present := pl["attachments"]; present {
|
||||
t.Errorf("attachments key MUST NOT be present when empty (canvas treats absence as 'none'); payload=%v", pl)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_REQUESTED", workspaceID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalRequested), workspaceID, map[string]interface{}{
|
||||
"approval_id": approvalID,
|
||||
"action": body.Action,
|
||||
"reason": body.Reason,
|
||||
@@ -62,7 +62,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
|
||||
var parentID *string
|
||||
db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
|
||||
if parentID != nil {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_ESCALATED", *parentID, map[string]interface{}{
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalEscalated), *parentID, map[string]interface{}{
|
||||
"approval_id": approvalID,
|
||||
"from_workspace_id": workspaceID,
|
||||
"action": body.Action,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user