Compare commits
11 Commits
main
...
fix/canvas
| Author | SHA1 | Date | |
|---|---|---|---|
| 9279f9292b | |||
| a832bd805c | |||
| 6958cd7966 | |||
| d4d3306150 | |||
| a3c9f0b717 | |||
| de9f46ea30 | |||
| 7ff5622a42 | |||
| bea89ce4e9 | |||
| 14f05b5a64 | |||
| 7caee806df | |||
| a914f675a4 |
@ -32,11 +32,9 @@ on:
|
|||||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Serialize per-branch so two rapid staging pushes don't race the same
|
# Serialize per-branch so two rapid main pushes don't race the same
|
||||||
# :staging-latest tag retag. Allow staging and main to run in parallel
|
# :staging-latest tag retag. Allow parallel runs as they produce
|
||||||
# (different GITHUB_REF → different concurrency group) since they
|
# different :staging-<sha> tags and last-write-wins on :staging-latest.
|
||||||
# produce different :staging-<sha> tags and last-write-wins on
|
|
||||||
# :staging-latest is acceptable across branches.
|
|
||||||
#
|
#
|
||||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||||
# build queues. This avoids a partially-pushed image.
|
# build queues. This avoids a partially-pushed image.
|
||||||
|
|||||||
1
.staging-trigger
Normal file
1
.staging-trigger
Normal file
@ -0,0 +1 @@
|
|||||||
|
staging trigger
|
||||||
@ -31,17 +31,25 @@ export function extractMessageText(body: Record<string, unknown> | null): string
|
|||||||
if (text) return text;
|
if (text) return text;
|
||||||
|
|
||||||
// Response: result.parts[].text or result.parts[].root.text
|
// Response: result.parts[].text or result.parts[].root.text
|
||||||
|
// Use the first part that has a direct text field; within that part,
|
||||||
|
// prefer direct text over root.text. Subsequent parts' root.text fields
|
||||||
|
// are ignored when a direct text exists in an earlier part.
|
||||||
const result = body.result as Record<string, unknown> | undefined;
|
const result = body.result as Record<string, unknown> | undefined;
|
||||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||||
const rText = rParts
|
const firstPartWithText = rParts.find(
|
||||||
.map((p) => {
|
(p) => typeof p.text === "string" && (p.text as string) !== ""
|
||||||
if (p.text) return p.text as string;
|
);
|
||||||
const root = p.root as Record<string, unknown> | undefined;
|
if (firstPartWithText) {
|
||||||
return (root?.text as string) || "";
|
return firstPartWithText.text as string;
|
||||||
})
|
}
|
||||||
.filter(Boolean)
|
// No direct text found; use root.text from the first part (if present).
|
||||||
.join("\n");
|
const firstPart = rParts[0];
|
||||||
if (rText) return rText;
|
if (firstPart) {
|
||||||
|
const root = firstPart.root as Record<string, unknown> | undefined;
|
||||||
|
if (typeof root?.text === "string" && root.text !== "") {
|
||||||
|
return root.text as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof body.result === "string") return body.result;
|
if (typeof body.result === "string") return body.result;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|||||||
@ -3,52 +3,56 @@
|
|||||||
* Tests for Spinner component.
|
* Tests for Spinner component.
|
||||||
*
|
*
|
||||||
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
||||||
|
*
|
||||||
|
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
|
||||||
|
* so we use getAttribute("class") instead of className for assertions.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, cleanup } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { Spinner } from "../Spinner";
|
import { Spinner } from "../Spinner";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function getSvgClass(r: ReturnType<typeof render>): string {
|
||||||
|
const svg = r.container.querySelector("svg");
|
||||||
|
if (!svg) throw new Error("No SVG found");
|
||||||
|
return svg.getAttribute("class") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
describe("Spinner — size variants", () => {
|
describe("Spinner — size variants", () => {
|
||||||
it("renders with sm size class", () => {
|
it("renders with sm size class", () => {
|
||||||
const { container } = render(<Spinner size="sm" />);
|
const r = render(<Spinner size="sm" />);
|
||||||
const svg = container.querySelector("svg");
|
expect(getSvgClass(r)).toContain("w-3");
|
||||||
expect(svg).toBeTruthy();
|
expect(getSvgClass(r)).toContain("h-3");
|
||||||
expect(svg?.className).toContain("w-3");
|
|
||||||
expect(svg?.className).toContain("h-3");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders with md size class (default)", () => {
|
it("renders with md size class (default)", () => {
|
||||||
const { container } = render(<Spinner size="md" />);
|
const r = render(<Spinner size="md" />);
|
||||||
const svg = container.querySelector("svg");
|
expect(getSvgClass(r)).toContain("w-4");
|
||||||
expect(svg?.className).toContain("w-4");
|
expect(getSvgClass(r)).toContain("h-4");
|
||||||
expect(svg?.className).toContain("h-4");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders with lg size class", () => {
|
it("renders with lg size class", () => {
|
||||||
const { container } = render(<Spinner size="lg" />);
|
const r = render(<Spinner size="lg" />);
|
||||||
const svg = container.querySelector("svg");
|
expect(getSvgClass(r)).toContain("w-5");
|
||||||
expect(svg?.className).toContain("w-5");
|
expect(getSvgClass(r)).toContain("h-5");
|
||||||
expect(svg?.className).toContain("h-5");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to md size when no size prop given", () => {
|
it("defaults to md size when no size prop given", () => {
|
||||||
const { container } = render(<Spinner />);
|
const r = render(<Spinner />);
|
||||||
const svg = container.querySelector("svg");
|
expect(getSvgClass(r)).toContain("w-4");
|
||||||
expect(svg?.className).toContain("w-4");
|
expect(getSvgClass(r)).toContain("h-4");
|
||||||
expect(svg?.className).toContain("h-4");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has aria-hidden=true so screen readers skip it", () => {
|
it("has aria-hidden=true so screen readers skip it", () => {
|
||||||
const { container } = render(<Spinner />);
|
const r = render(<Spinner />);
|
||||||
const svg = container.querySelector("svg");
|
const svg = r.container.querySelector("svg");
|
||||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||||
const { container } = render(<Spinner />);
|
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
|
||||||
const svg = container.querySelector("svg");
|
|
||||||
expect(svg?.className).toContain("motion-safe:animate-spin");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders exactly one SVG element", () => {
|
it("renders exactly one SVG element", () => {
|
||||||
|
|||||||
@ -44,3 +44,4 @@
|
|||||||
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
// Triggered by Integration Tester at 2026-05-10T08:52Z
|
||||||
|
|||||||
@ -37,6 +37,50 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
|
|||||||
EXPECTED=0
|
EXPECTED=0
|
||||||
CLONED=0
|
CLONED=0
|
||||||
|
|
||||||
|
# clone_one_with_retry — clone a single repo, retrying on transient failure.
|
||||||
|
#
|
||||||
|
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
|
||||||
|
# clone the full manifest (~36 repos) serially on a memory-constrained
|
||||||
|
# Gitea Actions runner. Under host memory pressure the OOM killer
|
||||||
|
# occasionally SIGKILLs git-remote-https mid-clone:
|
||||||
|
#
|
||||||
|
# error: git-remote-https died of signal 9
|
||||||
|
# fatal: the remote end hung up unexpectedly
|
||||||
|
#
|
||||||
|
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
|
||||||
|
# job died on the 14th of 36 clones, which wedged staging→main). One
|
||||||
|
# transient SIGKILL / network blip would otherwise fail the whole tenant
|
||||||
|
# image rebuild. Retrying after a short backoff lets the pressure subside.
|
||||||
|
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
|
||||||
|
# just stops a single flake from being release-blocking.
|
||||||
|
#
|
||||||
|
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
|
||||||
|
clone_one_with_retry() {
|
||||||
|
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
|
||||||
|
local attempt=1 max_attempts=3 backoff
|
||||||
|
|
||||||
|
while : ; do
|
||||||
|
# A killed attempt can leave a partial directory behind; git clone
|
||||||
|
# refuses a non-empty target, so wipe it before each try.
|
||||||
|
rm -rf "$tdir/$name"
|
||||||
|
|
||||||
|
if [ "$ref" = "main" ]; then
|
||||||
|
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
|
||||||
|
else
|
||||||
|
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||||
|
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
backoff=$((attempt * 3)) # 3s, then 6s
|
||||||
|
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
|
||||||
|
sleep "$backoff"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
clone_category() {
|
clone_category() {
|
||||||
local category="$1"
|
local category="$1"
|
||||||
local target_dir="$2"
|
local target_dir="$2"
|
||||||
@ -82,11 +126,7 @@ clone_category() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
|
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
|
||||||
if [ "$ref" = "main" ]; then
|
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
|
||||||
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))
|
CLONED=$((CLONED + 1))
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
|
|||||||
@ -77,6 +77,16 @@ async def delegate_task(workspace_id: str, task: str) -> str:
|
|||||||
return str(result) if isinstance(result, str) else "(no text)"
|
return str(result) if isinstance(result, str) else "(no text)"
|
||||||
elif "error" in data:
|
elif "error" in data:
|
||||||
err = data["error"]
|
err = data["error"]
|
||||||
|
# Handle both string-form errors ("error": "some string")
|
||||||
|
# and object-form errors ("error": {"message": "...", "code": ...}).
|
||||||
|
msg = ""
|
||||||
|
if isinstance(err, dict):
|
||||||
|
msg = err.get("message", "")
|
||||||
|
elif isinstance(err, str):
|
||||||
|
msg = err
|
||||||
|
else:
|
||||||
|
msg = str(err)
|
||||||
|
return f"Error: {msg}"
|
||||||
msg = ""
|
msg = ""
|
||||||
if isinstance(err, dict):
|
if isinstance(err, dict):
|
||||||
msg = err.get("message", "")
|
msg = err.get("message", "")
|
||||||
|
|||||||
@ -51,6 +51,22 @@ class AdaptorSource:
|
|||||||
|
|
||||||
def _load_module_from_path(module_name: str, path: Path):
|
def _load_module_from_path(module_name: str, path: Path):
|
||||||
"""Import a Python file by absolute path. Returns the module or None on failure."""
|
"""Import a Python file by absolute path. Returns the module or None on failure."""
|
||||||
|
# Ensure the plugins_registry package and its submodules are importable in the
|
||||||
|
# fresh module namespace created by module_from_spec(). Plugin adapters
|
||||||
|
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
|
||||||
|
# which requires plugins_registry and its submodules to already be in sys.modules.
|
||||||
|
# We import and register them before exec_module so the plugin's own
|
||||||
|
# from ... import statements resolve correctly.
|
||||||
|
import sys
|
||||||
|
import plugins_registry
|
||||||
|
sys.modules.setdefault("plugins_registry", plugins_registry)
|
||||||
|
for _sub in ("builtins", "protocol", "raw_drop"):
|
||||||
|
try:
|
||||||
|
sub = importlib.import_module(f"plugins_registry.{_sub}")
|
||||||
|
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
|
||||||
|
except Exception:
|
||||||
|
# Submodule may not exist in all versions; skip if absent.
|
||||||
|
pass
|
||||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||||
if spec is None or spec.loader is None:
|
if spec is None or spec.loader is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
60
workspace/plugins_registry/test_resolve_plugin.py
Normal file
60
workspace/plugins_registry/test_resolve_plugin.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
|
||||||
|
|
||||||
|
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
|
||||||
|
can be loaded via _load_module_from_path() without ModuleNotFoundError.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure the plugins_registry package is importable
|
||||||
|
import plugins_registry
|
||||||
|
|
||||||
|
from plugins_registry import _load_module_from_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_adapter_with_plugins_registry_import():
|
||||||
|
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
|
||||||
|
# Write a temp adapter file that does the exact import from the bug report.
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
|
||||||
|
) as f:
|
||||||
|
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
|
||||||
|
f.write("assert Adaptor is not None\n")
|
||||||
|
adapter_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = _load_module_from_path("test_adapter", adapter_path)
|
||||||
|
assert module is not None, "module should load without error"
|
||||||
|
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
|
||||||
|
finally:
|
||||||
|
os.unlink(adapter_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_adapter_with_full_plugins_registry_import():
|
||||||
|
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
|
||||||
|
) as f:
|
||||||
|
f.write("from plugins_registry import InstallContext, resolve\n")
|
||||||
|
f.write("from plugins_registry.protocol import PluginAdaptor\n")
|
||||||
|
f.write("assert InstallContext is not None\n")
|
||||||
|
f.write("assert resolve is not None\n")
|
||||||
|
f.write("assert PluginAdaptor is not None\n")
|
||||||
|
adapter_path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = _load_module_from_path("test_adapter_full", adapter_path)
|
||||||
|
assert module is not None, "module should load without error"
|
||||||
|
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
|
||||||
|
assert hasattr(module, "resolve"), "module should expose resolve"
|
||||||
|
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
|
||||||
|
finally:
|
||||||
|
os.unlink(adapter_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_load_adapter_with_plugins_registry_import()
|
||||||
|
test_load_adapter_with_full_plugins_registry_import()
|
||||||
|
print("ALL TESTS PASS")
|
||||||
Loading…
Reference in New Issue
Block a user