Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68d7228a9 | |||
| 9843a970d3 | |||
| 4cc5b9ce77 | |||
| 19b4d81670 | |||
| bc6b384413 | |||
| e073fa87da | |||
| 0ba29227e9 | |||
| 0d04527a13 | |||
| 6c6a070bc6 | |||
| d57404b87b | |||
| 648ac4d61b | |||
| 7bde0ea64a | |||
| 89476ae330 | |||
| cc55e651f6 | |||
| e64b8f0f35 | |||
| 6f230fba38 | |||
| 054ca2f552 | |||
| a120c86756 | |||
| 5088a7273c | |||
| 57adcaae5f | |||
| eaf58bb8d4 | |||
| 93bd9c7295 | |||
| 3aee079310 | |||
| cf932cf34c |
@@ -274,7 +274,8 @@ def required_checks_env(audit_doc: dict) -> set[str]:
|
||||
found.append(v)
|
||||
if not found:
|
||||
sys.stderr.write(
|
||||
f"::error::REQUIRED_CHECKS env not found in any step of {AUDIT_WORKFLOW_PATH}\n"
|
||||
f"::error::REQUIRED_CHECKS env not found in any step of "
|
||||
f"{AUDIT_WORKFLOW_PATH}\n"
|
||||
)
|
||||
sys.exit(3)
|
||||
if len(found) > 1:
|
||||
@@ -387,7 +388,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
missing_from_needs = sorted(jobs - needs)
|
||||
if missing_from_needs:
|
||||
findings.append(
|
||||
"F1 — jobs in ci.yml NOT under sentinel `needs:` (sentinel doesn't gate them):\n"
|
||||
"F1 — jobs in ci.yml NOT under sentinel `needs:` "
|
||||
"(sentinel doesn't gate them):\n"
|
||||
+ "\n".join(f" - {n}" for n in missing_from_needs)
|
||||
)
|
||||
|
||||
@@ -397,7 +399,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
stale_needs = sorted(needs - jobs_all)
|
||||
if stale_needs:
|
||||
findings.append(
|
||||
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml (typo or removed job):\n"
|
||||
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml "
|
||||
"(typo or removed job):\n"
|
||||
+ "\n".join(f" - {n}" for n in stale_needs)
|
||||
)
|
||||
|
||||
@@ -405,7 +408,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
# Compute the contexts the CI YAML actually produces. The sentinel
|
||||
# is in (B) intentionally (`ci / all-required (pull_request)`); we
|
||||
# whitelist it explicitly.
|
||||
emitted_contexts = {expected_context(j) for j in jobs} | {expected_context(SENTINEL_JOB)}
|
||||
emitted_contexts = {
|
||||
expected_context(j) for j in jobs
|
||||
} | {expected_context(SENTINEL_JOB)}
|
||||
# Contexts NOT produced by ci.yml may still come from other
|
||||
# workflows in the repo (Secret scan etc). We can't enumerate
|
||||
# every workflow's emissions cheaply; instead, flag only contexts
|
||||
@@ -418,8 +423,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
)
|
||||
if stale_protection:
|
||||
findings.append(
|
||||
"F2 — protection `status_check_contexts` entries with `ci / ` prefix that NO "
|
||||
"job in ci.yml emits (stale name → silent advisory gate):\n"
|
||||
"F2 — protection `status_check_contexts` entries with `ci / ` "
|
||||
"prefix that NO job in ci.yml emits "
|
||||
"(stale name → silent advisory gate):\n"
|
||||
+ "\n".join(f" - {c}" for c in stale_protection)
|
||||
)
|
||||
|
||||
@@ -494,7 +500,8 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str:
|
||||
f"# Drift detected on `{REPO}/{branch}`",
|
||||
"",
|
||||
"Auto-filed by `.gitea/workflows/ci-required-drift.yml` "
|
||||
"(RFC [internal#219](https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
|
||||
"(RFC [internal#219]"
|
||||
"(https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
|
||||
"",
|
||||
"## Findings",
|
||||
"",
|
||||
@@ -547,12 +554,12 @@ def file_or_update(
|
||||
|
||||
if dry_run:
|
||||
print(f"::notice::[dry-run] would file/update drift issue for {branch}")
|
||||
print(f"::group::[dry-run] title")
|
||||
print("::group::[dry-run] title")
|
||||
print(title)
|
||||
print(f"::endgroup::")
|
||||
print(f"::group::[dry-run] body")
|
||||
print("::endgroup::")
|
||||
print("::group::[dry-run] body")
|
||||
print(body)
|
||||
print(f"::endgroup::")
|
||||
print("::endgroup::")
|
||||
return
|
||||
|
||||
existing = find_open_issue(title)
|
||||
|
||||
@@ -15,7 +15,6 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROFILES: dict[str, dict[str, str]] = {
|
||||
"ci": {
|
||||
"platform": r"^workspace-server/",
|
||||
@@ -153,7 +152,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
|
||||
parser.add_argument("--pr-base-sha", default="")
|
||||
parser.add_argument("--base-ref", default="")
|
||||
parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", ""))
|
||||
parser.add_argument(
|
||||
"--push-before",
|
||||
default=os.environ.get("GITHUB_EVENT_BEFORE", ""),
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
@@ -183,7 +183,9 @@ def required_contexts_green(
|
||||
status = latest_statuses.get(context)
|
||||
state = status_state(status or {})
|
||||
if state != "success":
|
||||
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
|
||||
if pr_labels and _is_tier_low_pending_ok(
|
||||
latest_statuses, context, pr_labels
|
||||
):
|
||||
continue # tier:low soft-fail: accept pending sop-checklist
|
||||
missing_or_bad.append(f"{context}={state or 'missing'}")
|
||||
return not missing_or_bad, missing_or_bad
|
||||
|
||||
@@ -13,11 +13,9 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import glob
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
SELF = ".gitea/workflows/lint-curl-status-capture.yml"
|
||||
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ def _ensure_labels(repo: str, names: list[str]) -> list[int]:
|
||||
if status != "ok" or not isinstance(labels, list):
|
||||
return []
|
||||
out: list[int] = []
|
||||
by_name = {l["name"]: l["id"] for l in labels if isinstance(l, dict)}
|
||||
by_name = {label["name"]: label["id"] for label in labels if isinstance(label, dict)}
|
||||
for n in names:
|
||||
if n in by_name:
|
||||
out.append(by_name[n])
|
||||
|
||||
@@ -82,7 +82,7 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -578,6 +578,7 @@ def close_open_red_issues_for_other_shas(
|
||||
current_sha: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
close_same_sha: bool = False,
|
||||
) -> int:
|
||||
"""When main is green at current_sha, close any open `[main-red]`
|
||||
issues whose title references a different SHA. Returns the number
|
||||
@@ -586,15 +587,25 @@ def close_open_red_issues_for_other_shas(
|
||||
Lineage note: we only close issues whose title prefix matches; if
|
||||
a human renamed the issue or added a suffix this won't touch it.
|
||||
That's intentional — manual editorial state takes precedence.
|
||||
|
||||
Args:
|
||||
close_same_sha: set True when the caller already knows main is
|
||||
green at current_sha (e.g. recovery block) and wants to close
|
||||
the open issue for THIS SHA too. Defaults False so the
|
||||
green-path callers never accidentally close an issue they just
|
||||
filed on the same tick.
|
||||
"""
|
||||
target_title = title_for(current_sha)
|
||||
open_red = list_open_red_issues()
|
||||
closed = 0
|
||||
for issue in open_red:
|
||||
if issue.get("title") == target_title:
|
||||
# Same SHA — caller should not have invoked this if main is
|
||||
# green. Skip defensively.
|
||||
continue
|
||||
if not close_same_sha:
|
||||
# Same SHA — caller should not have invoked this if main is
|
||||
# green. Skip defensively (guards against green-path callers
|
||||
# that accidentally pass the SHA they just filed for).
|
||||
continue
|
||||
# close_same_sha=True: close even this SHA's issue (recovery path)
|
||||
num = issue.get("number")
|
||||
if not isinstance(num, int):
|
||||
continue
|
||||
@@ -699,6 +710,10 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
f"{sha[:10]} but HEAD is now {recheck_sha[:10]} on "
|
||||
f"{WATCH_BRANCH}; next cron tick will re-evaluate."
|
||||
)
|
||||
# HEAD drifted — close any stale main-red issue for the prior SHA
|
||||
# before returning, so we don't leave stale open issues when main
|
||||
# is no longer pointing at the red commit.
|
||||
close_open_red_issues_for_other_shas(recheck_sha, dry_run=dry_run)
|
||||
return 0
|
||||
|
||||
recheck_status = get_combined_status(sha)
|
||||
@@ -711,6 +726,9 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
f"{recheck_status.get('state')!r} on recheck; "
|
||||
f"initial red was a transient cancel-cascade."
|
||||
)
|
||||
# CI recovered on the same SHA — close any stale main-red issue
|
||||
# that was filed on a prior tick for this SHA.
|
||||
close_open_red_issues_for_other_shas(sha, dry_run=dry_run, close_same_sha=True)
|
||||
return 0
|
||||
|
||||
# Still red after settling — file/update. Use the recheck data
|
||||
|
||||
@@ -17,7 +17,6 @@ import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# ≥ 1 review on the PR where:
|
||||
# • state == APPROVED
|
||||
# • review.dismissed == false
|
||||
# • review.official != false (excludes draft/mis-filed APPROVED reviews)
|
||||
# • review.user.login != PR.user.login (non-author)
|
||||
# • review.user.login ∈ team-members
|
||||
#
|
||||
@@ -201,6 +202,7 @@ fi
|
||||
JQ_FILTER='.[]
|
||||
| select(.state == "APPROVED")
|
||||
| select(.dismissed != true)
|
||||
| select(.official != false)
|
||||
| select(.user.login != $author)'
|
||||
if [ "${REVIEW_CHECK_STRICT:-}" = "1" ]; then
|
||||
JQ_FILTER="${JQ_FILTER}
|
||||
|
||||
@@ -338,7 +338,6 @@ def compute_ack_state(
|
||||
# Filter out self-acks and unknown slugs.
|
||||
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
|
||||
for (user, slug), kind in latest_directive.items():
|
||||
@@ -842,7 +841,7 @@ def render_status(
|
||||
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
|
||||
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
|
||||
labels = pr.get("labels") or []
|
||||
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
|
||||
tier_labels = [label.get("name", "") for label in labels if (label.get("name", "") or "").startswith("tier:")]
|
||||
mode_map = cfg.get("tier_failure_mode") or {}
|
||||
default_mode = cfg.get("default_mode", "hard")
|
||||
for tl in tier_labels:
|
||||
@@ -865,7 +864,7 @@ def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
|
||||
Governance fix for internal#442 — closes the inconsistency between
|
||||
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
|
||||
"""
|
||||
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
|
||||
label_set = {(label.get("name") or "") for label in (pr.get("labels") or [])}
|
||||
if "tier:high" in label_set:
|
||||
return True
|
||||
high_risk_labels = set(cfg.get("high_risk_labels") or [])
|
||||
|
||||
@@ -33,7 +33,6 @@ import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
|
||||
STATE_DIR = os.environ.get("FIXTURE_STATE_DIR", "/tmp")
|
||||
|
||||
|
||||
@@ -81,7 +80,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /repos/{owner}/{name}/pulls/{pr_number}
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)$", path)
|
||||
if m:
|
||||
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
|
||||
pr_num = m.group(3)
|
||||
if sc == "T2_pr_closed":
|
||||
return self._json(200, {
|
||||
"number": int(pr_num),
|
||||
@@ -151,7 +150,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /teams/{team_id}/members/{username}
|
||||
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
|
||||
if m:
|
||||
team_id, login = m.group(1), m.group(2)
|
||||
login = m.group(2)
|
||||
if sc == "T8_team_not_member":
|
||||
return self._empty(404)
|
||||
if sc == "T9_team_403":
|
||||
|
||||
@@ -2,7 +2,6 @@ import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
|
||||
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
|
||||
mq = importlib.util.module_from_spec(spec)
|
||||
|
||||
@@ -15,7 +15,6 @@ Mirrors the pattern in scripts/ops/test_check_migration_collisions.py
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,7 +22,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Resolve sibling script regardless of where pytest is invoked from.
|
||||
|
||||
@@ -239,12 +239,13 @@ jobs:
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
|
||||
CI_STATUS_TIMEOUT_SECONDS: "3600"
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
|
||||
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
./scripts/dev-start.sh
|
||||
```
|
||||
|
||||
Then open [http://localhost:3000](http://localhost:3000), add your model API key in **Config → Secrets & API Keys → Global**, and create a workspace from a template.
|
||||
|
||||
See the full [Quickstart Guide](./docs/quickstart.md) for prerequisites, manual setup, and troubleshooting.
|
||||
|
||||
## The Pitch
|
||||
|
||||
Molecule AI is the most powerful way to govern an AI agent organization in production.
|
||||
|
||||
@@ -224,12 +224,14 @@ export function Toolbar() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "?") return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
const tag = target.tagName;
|
||||
const inInput =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
target.isContentEditable;
|
||||
if (inInput) return;
|
||||
// Don't fire when a modal/dialog is already mounted (canvas modals,
|
||||
// side panel, etc. use z-50 or above).
|
||||
|
||||
@@ -201,15 +201,13 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
|
||||
expect(label?.textContent).toContain("Budget limit");
|
||||
});
|
||||
|
||||
it("Template input has a <label> whose htmlFor matches the input id", async () => {
|
||||
it("Workspace Template select has a <label> whose htmlFor matches the select id", async () => {
|
||||
await openDialog();
|
||||
const templateInput = screen.getByPlaceholderText(
|
||||
"e.g. seo-agent (from workspace-configs-templates/)"
|
||||
) as HTMLInputElement;
|
||||
expect(templateInput.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${templateInput.id}"]`);
|
||||
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
|
||||
expect(templateSelect.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${templateSelect.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Template");
|
||||
expect(label?.textContent).toContain("Workspace Template");
|
||||
});
|
||||
|
||||
it("each InputField generates a distinct id (no id collisions)", async () => {
|
||||
@@ -218,13 +216,16 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
|
||||
screen.getByPlaceholderText("e.g. SEO Agent"),
|
||||
screen.getByPlaceholderText("e.g. SEO Specialist"),
|
||||
screen.getByPlaceholderText("e.g. 100"),
|
||||
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
|
||||
] as HTMLInputElement[];
|
||||
const selects = [
|
||||
screen.getByLabelText("Runtime"),
|
||||
screen.getByLabelText("Workspace Template"),
|
||||
] as HTMLSelectElement[];
|
||||
|
||||
const ids = inputs.map((i) => i.id).filter(Boolean);
|
||||
const ids = [...inputs, ...selects].map((i) => i.id).filter(Boolean);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length); // no duplicates
|
||||
expect(ids.length).toBe(4);
|
||||
expect(ids.length).toBe(5);
|
||||
});
|
||||
|
||||
it("Name label text contains the required asterisk indicator", async () => {
|
||||
|
||||
@@ -68,7 +68,11 @@ afterEach(() => {
|
||||
|
||||
function ShortcutTestComponent() {
|
||||
useKeyboardShortcuts();
|
||||
return <div data-testid="canvas-root" />;
|
||||
return (
|
||||
<div data-testid="canvas-root">
|
||||
<div data-testid="display-stream" data-display-stream="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithProvider() {
|
||||
@@ -78,6 +82,13 @@ function renderWithProvider() {
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Esc — deselect / close context menu", () => {
|
||||
it("does not handle keys targeted at the display stream", () => {
|
||||
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
|
||||
const { getByTestId } = renderWithProvider();
|
||||
fireEvent.keyDown(getByTestId("display-stream"), { key: "Escape" });
|
||||
expect(mockStoreState.closeContextMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the context menu when one is open", () => {
|
||||
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
|
||||
renderWithProvider();
|
||||
|
||||
@@ -28,12 +28,14 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
const tag = target.tagName;
|
||||
const inInput =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
target.isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
const state = useCanvasStore.getState();
|
||||
|
||||
@@ -313,11 +313,21 @@ function DisplayControlBar({
|
||||
|
||||
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rfbRef = useRef<RFB | null>(null);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
|
||||
const [remoteClipboardText, setRemoteClipboardText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let rfb: RFB | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const setTemporaryClipboardStatus = (message: string) => {
|
||||
setClipboardStatus(message);
|
||||
if (clipboardTimer) clearTimeout(clipboardTimer);
|
||||
clipboardTimer = setTimeout(() => setClipboardStatus(null), 2500);
|
||||
};
|
||||
|
||||
async function connect() {
|
||||
setStreamError(null);
|
||||
@@ -328,9 +338,19 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
rfb = new mod.default(containerRef.current, stream.url, {
|
||||
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
|
||||
});
|
||||
rfbRef.current = rfb;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
rfb.focusOnClick = true;
|
||||
rfb.focus({ preventScroll: true });
|
||||
rfb.addEventListener("clipboard", (event: Event) => {
|
||||
const text = (event as CustomEvent<{ text?: string }>).detail?.text ?? "";
|
||||
if (!text) return;
|
||||
setRemoteClipboardText(text);
|
||||
void navigator.clipboard?.writeText(text)
|
||||
.then(() => setTemporaryClipboardStatus("Copied remote clipboard"))
|
||||
.catch(() => setTemporaryClipboardStatus("Remote clipboard ready"));
|
||||
});
|
||||
rfb.addEventListener("disconnect", (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
|
||||
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
|
||||
@@ -343,13 +363,83 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (clipboardTimer) clearTimeout(clipboardTimer);
|
||||
rfbRef.current = null;
|
||||
rfb?.disconnect();
|
||||
};
|
||||
}, [sessionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (!isDisplayEventTarget(containerRef.current, event.target)) return;
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!text) return;
|
||||
event.preventDefault();
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
rfbRef.current?.focus({ preventScroll: true });
|
||||
setClipboardStatus("Pasted to desktop");
|
||||
};
|
||||
window.addEventListener("paste", onPaste, true);
|
||||
return () => window.removeEventListener("paste", onPaste, true);
|
||||
}, []);
|
||||
|
||||
const pasteLocalClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard?.readText();
|
||||
if (!text) {
|
||||
setClipboardStatus("Clipboard is empty");
|
||||
return;
|
||||
}
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
rfbRef.current?.focus({ preventScroll: true });
|
||||
setClipboardStatus("Pasted to desktop");
|
||||
} catch {
|
||||
setClipboardStatus("Press Ctrl/Cmd+V while the desktop is focused");
|
||||
}
|
||||
};
|
||||
|
||||
const copyRemoteClipboard = async () => {
|
||||
if (!remoteClipboardText) {
|
||||
setClipboardStatus("No remote clipboard yet");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(remoteClipboardText);
|
||||
setClipboardStatus("Copied remote clipboard");
|
||||
} catch {
|
||||
setClipboardStatus("Browser blocked clipboard copy");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 bg-black">
|
||||
<div
|
||||
data-display-stream="true"
|
||||
className="relative min-h-0 flex-1 bg-black"
|
||||
onMouseDown={() => rfbRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
{clipboardStatus && (
|
||||
<span className="rounded border border-line/50 bg-black/80 px-2 py-1 text-[10px] text-white">
|
||||
{clipboardStatus}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={pasteLocalClipboard}
|
||||
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyRemoteClipboard}
|
||||
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!remoteClipboardText}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{streamError && (
|
||||
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
|
||||
{streamError}
|
||||
@@ -359,6 +449,13 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function isDisplayEventTarget(container: HTMLElement | null, target: EventTarget | null): boolean {
|
||||
if (!container) return false;
|
||||
if (target instanceof Node && container.contains(target)) return true;
|
||||
const active = document.activeElement;
|
||||
return active instanceof Node && container.contains(active);
|
||||
}
|
||||
|
||||
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
|
||||
const url = new URL(sessionUrl, window.location.href);
|
||||
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
|
||||
const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRFBConstructor: vi.fn(),
|
||||
mockRFBClipboardPasteFrom: vi.fn(),
|
||||
mockRFBFocus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -30,6 +32,12 @@ vi.mock("@novnc/novnc", () => ({
|
||||
this.options = options;
|
||||
mockRFBConstructor(target, url, options);
|
||||
}
|
||||
clipboardPasteFrom(text: string) {
|
||||
mockRFBClipboardPasteFrom(text);
|
||||
}
|
||||
focus(options?: FocusOptions) {
|
||||
mockRFBFocus(options);
|
||||
}
|
||||
disconnect() {}
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +50,8 @@ describe("DisplayTab", () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockRFBConstructor.mockReset();
|
||||
mockRFBClipboardPasteFrom.mockReset();
|
||||
mockRFBFocus.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
@@ -157,6 +167,43 @@ describe("DisplayTab", () => {
|
||||
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
|
||||
});
|
||||
|
||||
it("forwards browser paste events into the noVNC clipboard", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
const desktop = await screen.findByTitle("Workspace desktop");
|
||||
fireEvent.paste(desktop, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockRFBClipboardPasteFrom).toHaveBeenCalledWith("Paste Me");
|
||||
expect(mockRFBFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -15,6 +15,8 @@ export function useKeyboardShortcut(
|
||||
if (!enabled) return;
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
if (e.key !== key) return;
|
||||
if (meta && !e.metaKey) return;
|
||||
if (ctrl && !e.ctrlKey) return;
|
||||
|
||||
Vendored
+2
@@ -4,6 +4,8 @@ declare module "@novnc/novnc" {
|
||||
resizeSession: boolean;
|
||||
focusOnClick: boolean;
|
||||
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
|
||||
clipboardPasteFrom(text: string): void;
|
||||
disconnect(): void;
|
||||
focus(options?: FocusOptions): void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_diag_memory_root_writable_in_canary_mode(sim: CPSim) -> None:
|
||||
key = f"canary-probe-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
val = sim.probe_memory(key)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# /mcp may not be exposed on this template — canary 4 will
|
||||
# surface the real defect if memory is actually broken.
|
||||
if os.environ.get("CANARY_STRICT_MCP") == "1":
|
||||
|
||||
@@ -281,8 +281,8 @@ def main() -> int:
|
||||
for prefix, peers in sorted(open_pr_collisions.items()):
|
||||
peer_str = ", ".join(f"#{p['number']} ({p['headRefName']})" for p in peers)
|
||||
print(f"::error::migration prefix {prefix:03d} also claimed by open PR(s): {peer_str}")
|
||||
print(f"::error::rebase coordination needed — only one PR can land a given prefix; "
|
||||
f"renumber yours or theirs")
|
||||
print("::error::rebase coordination needed — only one PR can land a given prefix; "
|
||||
"renumber yours or theirs")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
# Round-trip: register a workspace as poll-mode (no callback URL) → POST a
|
||||
# multi-file chat upload → verify each file becomes (a) one
|
||||
# `chat_upload_receive` activity row and (b) one /pending-uploads row → fetch
|
||||
# the bytes back via the poll endpoint → ack → verify the row 404s on
|
||||
# subsequent fetch. Also pins cross-workspace bleed protection: workspace B
|
||||
# cannot read workspace A's pending uploads even with its own valid bearer.
|
||||
# the bytes back via the poll endpoint → ack → verify the row stays readable
|
||||
# during retention for refreshed canvas previews. Also pins cross-workspace
|
||||
# bleed protection: workspace B cannot read workspace A's pending uploads even
|
||||
# with its own valid bearer.
|
||||
#
|
||||
# Why this exists separately from test_chat_upload_e2e.sh: that script
|
||||
# covers the PUSH path (the workspace's own /internal/chat/uploads/ingest).
|
||||
@@ -218,14 +219,16 @@ case "$RE_ACK1_CODE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------- Phase 7: GET content after ack returns 404 ----------
|
||||
# ---------- Phase 7: GET content after ack remains readable ----------
|
||||
echo ""
|
||||
echo "--- Phase 7: Acked file 404s on subsequent fetch ---"
|
||||
echo "--- Phase 7: Acked file remains readable during retention ---"
|
||||
|
||||
POST_ACK=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
|
||||
"$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
|
||||
POST_ACK_CODE=$(printf '%s' "$POST_ACK" | tail -n1)
|
||||
check_eq "acked alpha returns HTTP 404" "404" "$POST_ACK_CODE"
|
||||
POST_ACK_BODY=$(printf '%s' "$POST_ACK" | sed '$d')
|
||||
check_eq "acked alpha returns HTTP 200" "200" "$POST_ACK_CODE"
|
||||
check_eq "acked alpha bytes still readable" "$EXPECTED1" "$POST_ACK_BODY"
|
||||
|
||||
# ---------- Phase 8: cross-workspace bleed protection ----------
|
||||
echo ""
|
||||
|
||||
@@ -18,9 +18,7 @@ No network. No live Gitea calls.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
@@ -55,9 +55,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -164,7 +162,7 @@ def test_bp_orphan_context_fails(envset, monkeypatch, capsys):
|
||||
" all-required:\n runs-on: x\n steps:\n - run: echo hi\n",
|
||||
)
|
||||
m = _import_lint()
|
||||
posted = _stub_api(
|
||||
_stub_api(
|
||||
monkeypatch,
|
||||
m,
|
||||
("ok", {"status_check_contexts": [
|
||||
|
||||
@@ -60,10 +60,8 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -53,10 +53,7 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -61,9 +61,7 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
+108
-11
@@ -37,7 +37,6 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -117,15 +116,25 @@ def _make_stub_api(responses: dict):
|
||||
|
||||
def __call__(self, method, path, *, body=None, query=None, expect_json=True):
|
||||
self.calls.append((method, path, body, query))
|
||||
# If we've stored a list for this (method, path), rotate through.
|
||||
# This supports tests that need sequential responses for the
|
||||
# same endpoint without adding query-param noise.
|
||||
key = (method, path)
|
||||
if key not in responses:
|
||||
raise AssertionError(
|
||||
f"unexpected api call: {method} {path} (no stub registered)"
|
||||
)
|
||||
r = responses[key]
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
r = responses.get(key)
|
||||
if isinstance(r, list):
|
||||
if not r:
|
||||
raise AssertionError(
|
||||
f"stub sequential responses exhausted for {method} "
|
||||
f"{path} — provisioned {len(r)} entries"
|
||||
)
|
||||
return r.pop(0)
|
||||
if r is not None:
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
raise AssertionError(
|
||||
f"unexpected api call: {method} {path} (no stub registered)"
|
||||
)
|
||||
|
||||
return StubApi()
|
||||
|
||||
@@ -133,6 +142,7 @@ def _make_stub_api(responses: dict):
|
||||
# Sample SHA used throughout. 40 chars per Gitea convention.
|
||||
SHA_RED = "deadbeefcafe1234567890abcdef000011112222"
|
||||
SHA_GREEN = "ababababcdcdcdcd0000111122223333deadc0de"
|
||||
SHA_NEW = "aaaabbbbccccddddeeeeffff0000111122223333"
|
||||
|
||||
|
||||
def _branches_response(sha: str) -> dict:
|
||||
@@ -140,6 +150,19 @@ def _branches_response(sha: str) -> dict:
|
||||
return {"name": "main", "commit": {"id": sha}}
|
||||
|
||||
|
||||
def _branch_alt(sha: str) -> dict:
|
||||
"""Identical shape but to a different key path so _make_stub_api
|
||||
retains a separate first-response entry from the primary
|
||||
_branches_response() path.
|
||||
|
||||
The stub stores only the first response per (method, path) pair.
|
||||
Tests that need two distinct responses for the same logical
|
||||
GET /branches/main call use _branch_alt for the second lookup so
|
||||
the stub returns the correct sequential entry.
|
||||
"""
|
||||
return {"name": "main", "commit": {"id": sha}}
|
||||
|
||||
|
||||
def _combined_status(state: str, statuses: list[dict] | None = None) -> dict:
|
||||
"""Shape Gitea returns from /commits/{sha}/status."""
|
||||
return {"state": state, "statuses": statuses or []}
|
||||
@@ -542,7 +565,6 @@ def test_auto_close_skips_when_main_pending(wd_module, monkeypatch):
|
||||
"""main pending (CI still running) at NEW_SHA → leave old issue alone.
|
||||
Pending could resolve to red, so closing prematurely would lose the
|
||||
breadcrumb of the prior red."""
|
||||
old_title = f"[main-red] owner/repo: {SHA_RED[:10]}"
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branches/main"): (200, _branches_response(SHA_GREEN)),
|
||||
("GET", f"/repos/owner/repo/commits/{SHA_GREEN}/status"): (
|
||||
@@ -561,6 +583,81 @@ def test_auto_close_skips_when_main_pending(wd_module, monkeypatch):
|
||||
assert ("GET", "/repos/owner/repo/issues") not in methods_paths
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Stale-issue cleanup on transient / head-drift (internal#1789)
|
||||
# --------------------------------------------------------------------------
|
||||
def test_head_drift_closes_stale_issue_for_prior_sha(wd_module, monkeypatch):
|
||||
"""Initial red at SHA_RED. Before recheck, main is force-pushed to
|
||||
SHA_NEW (different commit). watchdog must close the stale SHA_RED
|
||||
issue before returning — otherwise stale open issues accumulate
|
||||
when main is force-pushed during a red window."""
|
||||
stub = _make_stub_api({
|
||||
# Initial check: branch SHA_RED, status failure
|
||||
("GET", "/repos/owner/repo/branches/main"): [
|
||||
(200, _branches_response(SHA_RED)),
|
||||
(200, _branch_alt(SHA_NEW)), # recheck branch call → HEAD moved
|
||||
(200, _branch_alt(SHA_NEW)), # close path branch call
|
||||
],
|
||||
("GET", f"/repos/owner/repo/commits/{SHA_RED}/status"): [
|
||||
(200, _combined_status("failure", [
|
||||
{"context": "ci/test", "status": "failure", "description": "broke"},
|
||||
])),
|
||||
(200, _combined_status("success", [ # recheck: CI result arrived
|
||||
{"context": "ci/test", "status": "success"},
|
||||
])),
|
||||
],
|
||||
(f"GET", f"/repos/owner/repo/commits/{SHA_NEW}/status"): [
|
||||
(200, _combined_status("success", [
|
||||
{"context": "ci/test", "status": "success"},
|
||||
])),
|
||||
],
|
||||
# close_open_red_issues_for_other_shas(SHA_NEW): issue for SHA_RED found
|
||||
("GET", "/repos/owner/repo/issues"): [
|
||||
(200, [{"number": 9, "title": f"[main-red] owner/repo: {SHA_RED[:10]}"}]),
|
||||
],
|
||||
("POST", "/repos/owner/repo/issues/9/comments"): (201, {"id": 200}),
|
||||
("PATCH", "/repos/owner/repo/issues/9"): (200, {"number": 9, "state": "closed"}),
|
||||
})
|
||||
monkeypatch.setattr(wd_module, "api", stub)
|
||||
rc = wd_module.run_once(dry_run=False)
|
||||
assert rc == 0
|
||||
methods_paths = [(c[0], c[1]) for c in stub.calls]
|
||||
assert ("PATCH", "/repos/owner/repo/issues/9") in methods_paths, \
|
||||
"head-drift should close the stale SHA_RED issue"
|
||||
|
||||
|
||||
def test_recovery_on_same_sha_closes_issue_filed_on_prior_tick(wd_module, monkeypatch):
|
||||
"""Same SHA shows red on initial check, but CI recovers before recheck
|
||||
completes. watchdog must close the issue that was filed on an earlier
|
||||
tick for this same SHA — otherwise stale open issues accumulate when CI
|
||||
recovers within the settling window."""
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branches/main"): (200, _branches_response(SHA_RED)),
|
||||
# Sequential: initial check → failure, recheck (≥2nd call) → success.
|
||||
# Using a list so Python dict keeps a single key (avoids overwrite).
|
||||
("GET", f"/repos/owner/repo/commits/{SHA_RED}/status"): [
|
||||
(200, _combined_status("failure", [
|
||||
{"context": "ci/test", "status": "failure", "description": "broke"},
|
||||
])),
|
||||
(200, _combined_status("success", [
|
||||
{"context": "ci/test", "state": "success"},
|
||||
])),
|
||||
],
|
||||
# List open red issues → find stale issue for this SHA
|
||||
("GET", "/repos/owner/repo/issues"): (
|
||||
200, [{"number": 11, "title": f"[main-red] owner/repo: {SHA_RED[:10]}"}],
|
||||
),
|
||||
("POST", "/repos/owner/repo/issues/11/comments"): (201, {"id": 300}),
|
||||
("PATCH", "/repos/owner/repo/issues/11"): (200, {"number": 11, "state": "closed"}),
|
||||
})
|
||||
monkeypatch.setattr(wd_module, "api", stub)
|
||||
rc = wd_module.run_once(dry_run=False)
|
||||
assert rc == 0
|
||||
methods_paths = [(c[0], c[1]) for c in stub.calls]
|
||||
assert ("PATCH", "/repos/owner/repo/issues/11") in methods_paths, \
|
||||
"recovery-on-same-SHA should close the stale issue"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP-failure / api() raises — duplicate-write regression guard
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -790,7 +887,7 @@ def test_emit_loki_event_prints_json_line(wd_module, capsys, monkeypatch):
|
||||
captured = capsys.readouterr()
|
||||
assert "main-red-watchdog event:" in captured.out
|
||||
# Find the JSON payload after the prefix and verify it parses
|
||||
line = [l for l in captured.out.splitlines() if "main-red-watchdog event:" in l][0]
|
||||
line = [ln for ln in captured.out.splitlines() if "main-red-watchdog event:" in ln][0]
|
||||
payload = json.loads(line.split("main-red-watchdog event:", 1)[1].strip())
|
||||
assert payload["event_type"] == "main_red_detected"
|
||||
assert payload["repo"] == "owner/repo"
|
||||
|
||||
@@ -40,7 +40,6 @@ Dependencies: stdlib + pytest + PyYAML. No network.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -853,7 +852,6 @@ def test_reap_skips_combined_success_shas(sr_module, monkeypatch):
|
||||
Mock 2 SHAs with combined=success + 1 with combined=failure → only
|
||||
the failure-SHA's statuses get the per-context loop applied.
|
||||
"""
|
||||
per_context_iterated_for: list[str] = []
|
||||
posts: list[tuple[str, dict]] = []
|
||||
|
||||
failure_statuses = [
|
||||
|
||||
@@ -23,11 +23,9 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── Gitea API client ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,9 +158,9 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
|
||||
# Build reverse map: login -> (group, agent_key)
|
||||
login_to_group = {}
|
||||
for group, login in relevant_roles.items():
|
||||
for role, l in AGENT_LOGIN_MAP.items():
|
||||
if l == login:
|
||||
login_to_group[l] = (group, f"core-{role}")
|
||||
for role, role_login in AGENT_LOGIN_MAP.items():
|
||||
if role_login == login:
|
||||
login_to_group[role_login] = (group, f"core-{role}")
|
||||
|
||||
# Collect all agent-tag matches from comments
|
||||
comments = []
|
||||
|
||||
@@ -124,13 +124,17 @@ func TestIntegration_PendingUploads_PutGetAckRoundTrip(t *testing.T) {
|
||||
t.Errorf("FetchedAt should be set after MarkFetched")
|
||||
}
|
||||
|
||||
// Ack flips acked_at; subsequent Gets return ErrNotFound (acked rows
|
||||
// are filtered out at the SELECT predicate).
|
||||
// Ack flips acked_at. Acked rows remain readable during retention so
|
||||
// refreshed canvas previews can resolve platform-pending: attachment URIs.
|
||||
if err := store.Ack(ctx, fileID); err != nil {
|
||||
t.Fatalf("Ack: %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, fileID); err != pendinguploads.ErrNotFound {
|
||||
t.Errorf("Get after Ack: got %v, want ErrNotFound", err)
|
||||
rec3, err := store.Get(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Ack: %v", err)
|
||||
}
|
||||
if rec3.AckedAt == nil {
|
||||
t.Errorf("AckedAt should be set after Ack")
|
||||
}
|
||||
|
||||
// Idempotent re-ack succeeds.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -6,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
@@ -16,22 +20,31 @@ import (
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
|
||||
// setupTokenTestDB creates an in-memory SQLite-like test or returns early
|
||||
// if the real Postgres test DB is available. For unit tests we use the
|
||||
// package-level db.DB which handlers rely on.
|
||||
// setupTokenTestDB connects to $INTEGRATION_DB_URL (skipping the test if
|
||||
// unset), sets the package-global db.DB for the duration of the test, and
|
||||
// returns a cleanup func that restores the previous db.DB value.
|
||||
func setupTokenTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
if db.DB == nil {
|
||||
t.Skip("db.DB not initialised — run with a test database")
|
||||
url := os.Getenv("INTEGRATION_DB_URL")
|
||||
if url == "" {
|
||||
t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: start a Postgres container and export INTEGRATION_DB_URL)")
|
||||
}
|
||||
// Quick probe — if the DB is closed or unreachable, skip.
|
||||
if err := db.DB.Ping(); err != nil {
|
||||
t.Skipf("db.DB not reachable: %v", err)
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open integration DB: %v", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
t.Fatalf("ping integration DB: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = conn
|
||||
return func() {
|
||||
db.DB = prevDB
|
||||
conn.Close()
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func TestTokenHandler_CreateAndList(t *testing.T) {
|
||||
func TestIntegration_TokenHandler_CreateAndList(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -94,7 +107,7 @@ func TestTokenHandler_CreateAndList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_Revoke(t *testing.T) {
|
||||
func TestIntegration_TokenHandler_Revoke(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -151,7 +164,7 @@ func TestTokenHandler_Revoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
func TestIntegration_TokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user