Compare commits

..

3 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) c8932a47a6 test(middleware): add missing unit tests for tenantSlug and cpSessionVerifyURL
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 26s
E2E Chat / detect-changes (pull_request) Successful in 21s
CI / Canvas (Next.js) (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
E2E Chat / E2E Chat (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 46s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 39s
CI / Canvas Deploy Status (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 19s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Has started running
Harness Replays / detect-changes (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Has started running
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Has started running
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Blocked by required conditions
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Has started running
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Has started running
Harness Replays / Harness Replays (pull_request) Successful in 22s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 1m3s
CI / Platform (Go) (pull_request) Successful in 4m17s
CI / all-required (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5m13s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge user_tasks (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Creates Workspace (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge (compile+skip) (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging Concierge Platform Agent (pull_request) Waiting to run
qa-review / approved (pull_request_target) Approved via pull_request_review trigger
qa-review / approved (pull_request_review) Successful in 6s
security-review / approved (pull_request_target) Approved via pull_request_review trigger
security-review / approved (pull_request_review) Successful in 12s
gate-check-v3 / gate-check (pull_request_target) Failing after 13s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 10s
audit-force-merge / audit (pull_request_target) Successful in 13s
Adds coverage for two previously-untested helpers in session_auth.go:
- TestTenantSlug: verifies slug read from MOLECULE_ORG_SLUG env var.
- TestTenantSlug_TrimSpace: verifies surrounding whitespace is trimmed.
- TestTenantSlug_Empty: verifies empty env returns empty string.
- TestCPSessionVerifyURL: verifies URL construction with CP_UPSTREAM_URL.
- TestCPSessionVerifyURL_TrailingSlash: verifies trailing slash is stripped.
- TestCPSessionVerifyURL_EscapeSlug: verifies slug is URL-encoded.
- TestCPSessionVerifyURL_NoCPConfigured: verifies empty CP_UPSTREAM_URL returns .

Full middleware suite (117 tests) passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:58:50 +00:00
molecule-code-reviewer 675ab9df83 Merge pull request 'fix(canvas): envelope flies dot→dot with a grow-then-shrink arc' (#2472) from fix/envelope-anchor-dot-and-scale into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Python Lint & Test (push) Successful in 7s
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Detect changes (push) Successful in 15s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
E2E Chat / detect-changes (push) Successful in 9s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
CI / Platform (Go) (push) Successful in 3s
CI / Shellcheck (E2E scripts) (push) Successful in 1s
E2E API Smoke Test / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 10s
Harness Replays / Harness Replays (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 15s
publish-canvas-image / Build & push canvas image (push) Successful in 1m38s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m43s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (push) Failing after 3m49s
E2E Chat / E2E Chat (push) Failing after 5m29s
CI / Canvas (Next.js) (push) Successful in 6m27s
CI / Canvas Deploy Status (push) Successful in 1s
publish-workspace-server-image / build-and-push (push) Successful in 6m30s
CI / all-required (push) Successful in 3s
publish-canvas-image / Promote canvas :latest to CI-green build (push) Successful in 5m7s
publish-workspace-server-image / Production auto-deploy (push) Failing after 4m8s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (push) Failing after 7m5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Failing after 34m26s
2026-06-09 15:51:39 +00:00
core-devops 4f0f7b24c3 fix(canvas): envelope flies dot→dot with a grow-then-shrink arc
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
E2E Chat / detect-changes (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 28s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 13s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 37s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
E2E Chat / E2E Chat (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 8s
gate-check-v3 / gate-check (pull_request_target) Successful in 15s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request_target) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 59s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 9s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Failing after 3m48s
CI / Canvas (Next.js) (pull_request) Successful in 6m43s
CI / Canvas Deploy Status (pull_request) Successful in 1s
CI / all-required (pull_request) Successful in 9s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Failing after 7m50s
qa-review / approved (pull_request_target) Approved via pull_request_review trigger
qa-review / approved (pull_request_review) Successful in 5s
security-review / approved (pull_request_target) Approved via pull_request_review trigger
security-review / approved (pull_request_review) Successful in 5s
audit-force-merge / audit (pull_request_target) Successful in 5s
Two issues with the A2A message envelope on the spatial canvas:

1. Wrong launch/land point — MessageFlightLayer anchored on each node's
   geometric CARD centre (position + measured/2), so envelopes appeared to come
   from/go to arbitrary points rather than the agent itself. Now they anchor on
   the workspace's STATUS DOT (the green/glowing presence indicator): the dot
   carries data-flight-anchor, and the layer reads its rendered rect and
   converts screen→flow via React Flow's screenToFlowPosition — exact regardless
   of pan/zoom, and robust to header-layout changes. Falls back to the card
   centre only when the dot isn't in the DOM yet. Anchors are captured ONCE per
   flight (capture-once ref, mirroring MessageFlightHome) so a pan/zoom mid-
   flight can't restart the animation.

2. Flat motion — the old keyframes scaled 0.45→1.0 monotonically. Now the
   envelope launches small from the source dot, GROWS BIG as it crosses the gap
   (peak scale 1.7 at mid-flight), then SHRINKS small as it lands on the target
   dot — reading as an envelope flung from one agent and received by the other.
   translate tracks the straight path (fraction == keyframe offset); scale arcs
   independently. Shared FlightEnvelope, so the concierge-home surface gets the
   same arc.

Tests: new FlightEnvelope.test.tsx locks the render contract (positioned at
`from`, kind→colour, graceful degradation when Element.animate is absent).
useA2AFlights hook test unchanged + green. tsc + eslint clean on the changed
source.

Note: the scale arc uses the Web Animations API (not unit-testable in jsdom) —
eyeball the live canvas to confirm the grow/shrink feel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:59:48 -07:00
6 changed files with 216 additions and 22 deletions
+15 -5
View File
@@ -40,14 +40,24 @@ export function FlightEnvelope({
if (!el || typeof el.animate !== "function") return;
const dx = to.x - from.x;
const dy = to.y - from.y;
// Launch small from the source dot, GROW BIG as it crosses the gap (peak
// mid-flight), then SHRINK small as it lands on the target dot — reads as an
// envelope flung from one agent and received by the other. translate tracks
// the straight path (fraction == keyframe offset); scale arcs independently.
const at = (frac: number, scale: number, opacity: number, offset?: number) => ({
transform: `translate(-50%,-50%) translate(${dx * frac}px,${dy * frac}px) scale(${scale})`,
opacity,
...(offset === undefined ? {} : { offset }),
});
const anim = el.animate(
[
{ transform: "translate(-50%,-50%) translate(0px,0px) scale(0.45)", opacity: 0 },
{ opacity: 1, offset: 0.16 },
{ opacity: 1, offset: 0.8 },
{ transform: `translate(-50%,-50%) translate(${dx}px,${dy}px) scale(1)`, opacity: 0 },
at(0, 0.5, 0),
at(0.2, 1.25, 1, 0.2), // faded in + grown
at(0.5, 1.7, 1, 0.5), // BIG at mid-flight
at(0.82, 1.05, 1, 0.82), // shrinking on approach
at(1, 0.5, 0), // small + faded out, arrived on the target dot
],
{ duration: FLIGHT_DURATION_MS, easing: "cubic-bezier(0.45, 0, 0.25, 1)", fill: "forwards" },
{ duration: FLIGHT_DURATION_MS, easing: "ease-in-out", fill: "forwards" },
);
return () => anim.cancel();
}, [from.x, from.y, to.x, to.y]);
+77 -16
View File
@@ -4,17 +4,25 @@
* Mounted INSIDE <ReactFlow> so its ViewportPortal places the envelope in flow
* coordinates; it therefore pans and zooms with the canvas for free. The
* flight lifecycle (which events become envelopes, reduced-motion opt-out,
* expiry) lives in useA2AFlights — this component only resolves node centres
* and renders. */
import { ViewportPortal, type Node } from "@xyflow/react";
* expiry) lives in useA2AFlights — this component only resolves endpoints and
* renders.
*
* Endpoints anchor on each workspace's STATUS DOT (the green/glowing presence
* indicator), not the card's geometric centre — so an envelope visibly leaves
* the source agent's dot and lands on the target agent's dot. The dot carries
* `data-flight-anchor`; we read its rendered rect and convert screen→flow via
* React Flow, falling back to the card centre only when the dot isn't in the
* DOM yet (node just mounted / scrolled out). */
import { useRef } from "react";
import { ViewportPortal, useReactFlow, type Node } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import { useA2AFlights } from "@/hooks/useA2AFlights";
import { useA2AFlights, type A2AFlight } from "@/hooks/useA2AFlights";
import { FlightEnvelope, type Point } from "./FlightEnvelope";
import type { WorkspaceNodeData } from "@/store/canvas";
// Fallback node footprint when React Flow has not measured a node yet. Matches
// WorkspaceNode's leaf size (w-[300px] min-h-[176px]); a slightly-off centre
// for the first frame after mount is invisible at flight scale.
// WorkspaceNode's leaf size (w-[300px] min-h-[176px]); a slightly-off centre for
// the first frame after mount is invisible at flight scale.
const DEFAULT_W = 300;
const DEFAULT_H = 176;
@@ -24,23 +32,76 @@ function nodeCenter(n: Node<WorkspaceNodeData>): Point {
return { x: n.position.x + w / 2, y: n.position.y + h / 2 };
}
/** Resolve a node's status-dot centre in FLOW coordinates. Reads the dot's
* rendered screen rect (it carries data-flight-anchor) and converts it back to
* flow space, so the anchor is exact regardless of pan/zoom and survives any
* header-layout change. Falls back to the card centre when the dot isn't
* rendered. */
function dotAnchor(
n: Node<WorkspaceNodeData>,
screenToFlowPosition: (p: Point) => Point,
): Point {
if (typeof document !== "undefined") {
const id =
typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(n.id) : n.id;
const el = document.querySelector<HTMLElement>(
`.react-flow__node[data-id="${id}"] [data-flight-anchor]`,
);
if (el) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
return screenToFlowPosition({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
}
}
}
return nodeCenter(n);
}
/** One flight. Captures the source/target dot anchors ONCE on mount (a ref, not
* per-render) so a pan/zoom or re-render mid-flight doesn't restart the
* animation — mirrors HomeFlight's capture-once contract. */
function CanvasFlight({
flight,
nodes,
screenToFlowPosition,
}: {
flight: A2AFlight;
nodes: Node<WorkspaceNodeData>[];
screenToFlowPosition: (p: Point) => Point;
}) {
const pos = useRef<{ from: Point; to: Point } | null>(null);
if (pos.current === null) {
const src = nodes.find((n) => n.id === flight.sourceId);
const dst = nodes.find((n) => n.id === flight.targetId);
// Both endpoints must be on-canvas to draw a path between them.
if (src && dst) {
pos.current = {
from: dotAnchor(src, screenToFlowPosition),
to: dotAnchor(dst, screenToFlowPosition),
};
}
}
if (!pos.current) return null;
return <FlightEnvelope from={pos.current.from} to={pos.current.to} kind={flight.kind} />;
}
export function MessageFlightLayer() {
const flights = useA2AFlights();
const nodes = useCanvasStore((s) => s.nodes);
const nodes = useCanvasStore((s) => s.nodes) as Node<WorkspaceNodeData>[];
const { screenToFlowPosition } = useReactFlow();
if (flights.length === 0) return null;
return (
<ViewportPortal>
{flights.map((f) => {
const src = nodes.find((n) => n.id === f.sourceId);
const dst = nodes.find((n) => n.id === f.targetId);
// Both endpoints must be on-canvas to draw a path between them.
if (!src || !dst) return null;
return (
<FlightEnvelope key={f.key} from={nodeCenter(src)} to={nodeCenter(dst)} kind={f.kind} />
);
})}
{flights.map((f) => (
<CanvasFlight
key={f.key}
flight={f}
nodes={nodes}
screenToFlowPosition={screenToFlowPosition}
/>
))}
</ViewportPortal>
);
}
+1 -1
View File
@@ -215,7 +215,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Header row */}
<div className="flex items-center justify-between gap-2 mb-2.5">
<div className="flex items-center gap-2.5 min-w-0">
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
<div data-flight-anchor className={`w-2.5 h-2.5 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
<span className="text-[15px] font-semibold text-ink truncate leading-tight">
{data.name}
</span>
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
/**
* Tests for FlightEnvelope — the envelope that animates from `from` to `to`.
*
* Locks the render contract the canvas + concierge-home both depend on:
* - the envelope is positioned at the `from` point (its launch anchor),
* - it is coloured by activity kind,
* - it degrades gracefully when Element.animate is unavailable (jsdom / SSR).
*
* The grow→shrink scale arc itself uses the Web Animations API, which jsdom
* does not implement, so we assert the static render + graceful degradation
* rather than keyframe values.
*/
import React from "react";
import { render, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { FlightEnvelope } from "../FlightEnvelope";
afterEach(cleanup);
describe("FlightEnvelope", () => {
it("positions the envelope at the `from` launch point", () => {
const { getByTestId } = render(
<FlightEnvelope from={{ x: 120, y: 240 }} to={{ x: 400, y: 60 }} kind="send" />,
);
const el = getByTestId("flight-envelope");
expect(el.style.left).toBe("120px");
expect(el.style.top).toBe("240px");
expect(el.querySelector("svg")).toBeTruthy();
});
it("colours the envelope by activity kind", () => {
const stroke = (kind: "send" | "receive" | "task") => {
const { container } = render(
<FlightEnvelope from={{ x: 0, y: 0 }} to={{ x: 10, y: 10 }} kind={kind} />,
);
const s = container.querySelector("rect")?.getAttribute("stroke");
cleanup();
return s;
};
expect(stroke("send")).toBe("#22d3ee");
expect(stroke("receive")).toBe("#8b5cf6");
expect(stroke("task")).toBe("#f5a623");
});
it("degrades to a static render (no throw) when Element.animate is unavailable", () => {
// jsdom does not implement Element.animate — the component must still render.
expect(typeof document.createElement("div").animate).not.toBe("function");
const { getByTestId } = render(
<FlightEnvelope from={{ x: 0, y: 0 }} to={{ x: 1, y: 1 }} kind="task" />,
);
expect(getByTestId("flight-envelope")).toBeTruthy();
});
});
@@ -272,6 +272,20 @@ func MarkQueueItemFailed(ctx context.Context, id, errMsg string) {
}
}
// QueueDepth returns the number of currently-queued (not dispatched/completed)
// items for a workspace. Used by the busy-return response body so callers
// can see how many ahead of them.
func QueueDepth(ctx context.Context, workspaceID string) int {
var n int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM a2a_queue WHERE workspace_id = $1 AND status = 'queued'`,
workspaceID,
).Scan(&n); err != nil {
log.Printf("A2AQueue: QueueDepth query failed for workspace %s: %v", workspaceID, err)
}
return n
}
// DropStaleQueueItems marks queued items older than maxAge as 'dropped' with a
// system-generated reason so PM agents stop processing stale post-incident noise.
// Called with a workspaceID to scope cleanup to one workspace, or empty to sweep
@@ -227,3 +227,58 @@ func TestCacheKey_SlugSeparator(t *testing.T) {
t.Errorf("cacheKey collides on ambiguous splits")
}
}
func TestTenantSlug(t *testing.T) {
t.Setenv("MOLECULE_ORG_SLUG", "acme-corp")
if got := tenantSlug(); got != "acme-corp" {
t.Errorf("tenantSlug() = %q, want %q", got, "acme-corp")
}
}
func TestTenantSlug_TrimSpace(t *testing.T) {
t.Setenv("MOLECULE_ORG_SLUG", " spaced-slug ")
if got := tenantSlug(); got != "spaced-slug" {
t.Errorf("tenantSlug() = %q, want %q", got, "spaced-slug")
}
}
func TestTenantSlug_Empty(t *testing.T) {
t.Setenv("MOLECULE_ORG_SLUG", "")
if got := tenantSlug(); got != "" {
t.Errorf("tenantSlug() = %q, want empty", got)
}
}
func TestCPSessionVerifyURL(t *testing.T) {
t.Setenv("CP_UPSTREAM_URL", "https://cp.test")
got := cpSessionVerifyURL("acme")
want := "https://cp.test/cp/auth/tenant-member?slug=acme"
if got != want {
t.Errorf("cpSessionVerifyURL() = %q, want %q", got, want)
}
}
func TestCPSessionVerifyURL_TrailingSlash(t *testing.T) {
t.Setenv("CP_UPSTREAM_URL", "https://cp.test/")
got := cpSessionVerifyURL("acme")
want := "https://cp.test/cp/auth/tenant-member?slug=acme"
if got != want {
t.Errorf("cpSessionVerifyURL() = %q, want %q", got, want)
}
}
func TestCPSessionVerifyURL_EscapeSlug(t *testing.T) {
t.Setenv("CP_UPSTREAM_URL", "https://cp.test")
got := cpSessionVerifyURL("acme corp")
want := "https://cp.test/cp/auth/tenant-member?slug=acme+corp"
if got != want {
t.Errorf("cpSessionVerifyURL() = %q, want %q", got, want)
}
}
func TestCPSessionVerifyURL_NoCPConfigured(t *testing.T) {
t.Setenv("CP_UPSTREAM_URL", "")
if got := cpSessionVerifyURL("acme"); got != "" {
t.Errorf("cpSessionVerifyURL() = %q, want empty when CP_UPSTREAM_URL unset", got)
}
}