diff --git a/docs/remote_job_monitoring.md b/docs/remote_job_monitoring.md index f96fce1d..b9a6df5b 100644 --- a/docs/remote_job_monitoring.md +++ b/docs/remote_job_monitoring.md @@ -1,19 +1,32 @@ # Remote Job Monitoring -MatCreator manages E2B sandboxes as durable, session-scoped remote jobs. The -remote-job control plane separates a sandbox's provider identity and liveness -from the agent step that created it, so the FastAPI frontend can observe and -control the sandbox after an agent, browser, or middleware request reconnects. +MatCreator manages sandboxes and batch jobs as durable, session-scoped remote +jobs. The remote-job control plane separates a job's provider identity and +liveness from the agent step that created it, so the FastAPI frontend can +observe and control it after an agent, browser, or middleware request +reconnects. + +Every provider-specific operation goes through a small adapter protocol (see +[Provider Plugin Architecture](#provider-plugin-architecture) below), so +`RemoteJobService`, `RemoteJobMonitor`, and the web API never branch on a +provider name. Built in providers today: `e2b` (interactive sandbox via the +E2B SDK), `bohr_sandbox` (interactive sandbox via the `bohr` CLI), and +`bohr_job` (batch/HPC-style job via `bohr job submit`). ## Architecture ```mermaid flowchart LR - Agent[Step executor] --> Tools[E2B tools] + Agent[Step executor] --> Tools[remote_job_tools] Tools --> Service[RemoteJobService] Service --> Store[(remote-jobs.db)] - Service --> Adapter[E2BSandboxAdapter] - Adapter --> Sandbox[E2B/Bohrium sandbox] + Service --> Registry[providers registry] + Registry --> E2B[E2BSandboxAdapter] + Registry --> BohrSbx[BohrSandboxAdapter] + Registry --> BohrJob[BohrJobAdapter] + E2B --> Sandbox[E2B/Bohrium sandbox] + BohrSbx --> Sandbox + BohrJob --> Batch[Bohrium batch job] Monitor[RemoteJobMonitor] --> Service Monitor --> Store @@ -23,7 +36,7 @@ flowchart LR ``` The SQLite record is the source of truth for MatCreator's normalized job -lifecycle. The provider sandbox remains the source of truth for provider +lifecycle. The provider job/sandbox remains the source of truth for provider liveness. This distinction lets the UI report both a meaningful lifecycle state and the latest connectivity observation without conflating them. @@ -32,28 +45,38 @@ and the latest connectivity observation without conflating them. | Component | Location | Responsibility | | --- | --- | --- | | `RemoteJobStore` | `src/matcreator/control_plane/remote_jobs.py` | Persists jobs, lifecycle transitions, provider snapshots, and user-control events in SQLite. | -| `RemoteJobService` | `src/matcreator/control_plane/remote_job_service.py` | Coordinates E2B operations with durable records and enforces valid lifecycle operations. | -| `E2BSandboxAdapter` | `src/matcreator/control_plane/e2b.py` | Small lazy-import boundary around the E2B SDK for sandbox creation, commands, files, pause, kill, and probing. | -| `RemoteJobMonitor` | `src/matcreator/control_plane/remote_job_monitor.py` | Periodically reconciles active E2B records and applies bounded backoff after failed probes. | -| Agent tools | `src/matcreator/agents/execution_agent/e2b_tools.py` | Submit and operate on jobs owned by the current session. | -| Middleware APIs | `web/main.py` | List jobs/events and offer session-owner pause, terminate, and refresh endpoints. | +| `RemoteJobService` | `src/matcreator/control_plane/remote_job_service.py` | Coordinates provider operations with durable records and enforces valid lifecycle operations, dispatching to the adapter registered for each job's `provider`. | +| `RemoteJobAdapter` protocol | `src/matcreator/control_plane/providers/base.py` | The boundary every provider implements: `create`/`status`/`cancel` are mandatory; `pause`/`resume`/`run_command`/`upload_file`/`download_file`/`collect_outputs` are gated by declared `RemoteJobCapability` flags. | +| Provider registry | `src/matcreator/control_plane/providers/registry.py` | Maps a provider name to a lazily constructed adapter instance. | +| `E2BSandboxAdapter` | `src/matcreator/control_plane/providers/e2b.py` | Interactive sandbox via the E2B SDK: create, commands, files, pause, kill, probe. | +| `BohrSandboxAdapter` | `src/matcreator/control_plane/providers/bohr_sandbox.py` | Interactive sandbox via the `bohr` CLI (`bohr sandbox create/exec/files/describe/delete`). No pause/resume — the CLI has no such subcommand. | +| `BohrJobAdapter` | `src/matcreator/control_plane/providers/bohr_job.py` | Batch/HPC-style job via the `bohr` CLI (`bohr job submit/describe/download/terminate`). Submit-time inputs only; no interactive exec. | +| `RemoteJobMonitor` | `src/matcreator/control_plane/remote_job_monitor.py` | Periodically reconciles active jobs of every registered provider, using each adapter's own `poll_interval_seconds` for backoff scheduling. | +| Agent tools | `src/matcreator/agents/execution_agent/remote_job_tools.py` | Provider-specific submit tools (`submit_e2b_sandbox`, `submit_bohr_sandbox`, `submit_bohr_job`) plus provider-generic post-submission tools that dispatch on `job_id` alone. | +| Middleware APIs | `web/main.py` | List jobs/events and offer session-owner pause, terminate, and refresh endpoints, generic across providers. | ## Submission and Persistence -`submit_e2b_sandbox` requires an explicit template. It creates a deterministic -idempotency key from the session, execution node, and template, then delegates -to `RemoteJobService.submit_e2b`. +Submission is provider-specific — an interactive sandbox needs a template +while a batch job needs a machine type and image — so there is one submit +tool per provider: `submit_e2b_sandbox`, `submit_bohr_sandbox`, +`submit_bohr_job`. Each builds a deterministic idempotency key from the +session, execution node, and a provider-specific discriminator, then +delegates to `RemoteJobService.submit_job(provider=..., spec=...)`. The service creates the SQLite job record before making the provider request. -The persisted specification contains the template, endpoint, project ID, -timeout, lifecycle policy, and metadata, but never the API key. Repeated calls -with the same idempotency key return the existing job instead of creating a -second sandbox. - -Once sandbox creation succeeds, the service stores the provider sandbox ID in -`external_id` and transitions the job to `running`. Agent recovery records the -job reference against the execution graph so an interrupted execution can wait -for or accurately report an existing sandbox rather than resubmitting it. +`persisted_specification` — everything in `spec` except secrets like an API +key — is what actually gets stored; `spec` itself (which may contain +secrets) is passed to the adapter's `create` but never persisted. Repeated +calls with the same idempotency key return the existing job instead of +creating a second sandbox or job. + +Once creation succeeds, the service stores the provider-side ID in +`external_id`, probes the adapter once for an initial status (letting a batch +provider start in `queued` instead of always assuming `running`), and +transitions the job accordingly. Agent recovery records the job reference +against the execution graph so an interrupted execution can wait for or +accurately report an existing job rather than resubmitting it. ## Lifecycle and Observations @@ -62,7 +85,8 @@ Important normalized states include: - `created`, `submitting`, `queued`, `running`, `paused`, and `resuming` for active work. -- `succeeded` and `collecting` while a job's results are being handled. +- `succeeded` and `collecting` while a batch job's results are being pulled + via `collect_remote_job_outputs`. - `collected`, `failed`, `cancelled`, `terminated`, and `lost` as terminal outcomes. @@ -71,16 +95,27 @@ transitions use optimistic concurrency checks, so stale pause, terminate, or provider updates cannot silently overwrite newer state. Provider probe data is stored in `snapshot`; examples include -`provider_status`, `sandbox_id`, `last_command_exit_code`, and `last_upload`. -An observation does not itself alter the normalized lifecycle state. +`provider_status`, `sandbox_id`, `phase` (for a batch job), `last_command_exit_code`, +and `last_upload`. An observation does not itself alter the normalized +lifecycle state unless the adapter reports a `normalized_status` that differs +from the current one — see [Provider Plugin Architecture](#provider-plugin-architecture). ## Monitoring and Refresh -`RemoteJobMonitor` considers active E2B jobs and probes jobs in `queued`, -`running`, `submitting`, or `resuming` states. A successful probe records a -reachable provider snapshot. A failed probe records `provider_status` as -`unreachable` and increases the next probe delay exponentially, bounded by the -configured maximum backoff. +`RemoteJobMonitor` considers active jobs of every registered provider and +probes jobs in `queued`, `running`, `submitting`, or `resuming` states, using +each job's own adapter to decide how — and how often — to probe. A batch +provider like `bohr_job` declares a much longer `poll_interval_seconds` (60s) +than an interactive sandbox (15s), so it is polled far less often without any +special-casing in the monitor itself. + +For an interactive adapter (`e2b`, `bohr_sandbox`) a successful probe records +a reachable provider snapshot; a failed probe records `provider_status` as +`unreachable` and increases the next probe delay exponentially, bounded by +the configured maximum backoff. For a batch adapter (`bohr_job`) the same +probe can report a `normalized_status` change (e.g. `queued` -> `running` -> +`succeeded`/`failed`/`cancelled`), which the service turns into an actual +lifecycle transition instead of just an observation. Monitor schedules are intentionally in memory. The job records themselves are durable, so a restarted monitor begins by reconciling active jobs from SQLite. @@ -129,11 +164,11 @@ mechanism rather than two. Re-attachment is explicit rather than accidental. When a node that already owns a job runs again, the runner injects the job's identity into the executor's -`prior_context` with instructions to call `get_e2b_job_status` and never call -`submit_e2b_sandbox` for that step. In Flash mode, which has no execution graph, -a step's node ID is derived from its label or a hash of its action, so a repeated -step keeps the same submission idempotency key and re-attaches instead of -creating a duplicate sandbox. +`prior_context` with instructions to call `get_remote_job_status` and never +call any of the `submit_*` tools for that step. In Flash mode, which has no +execution graph, a step's node ID is derived from its label or a hash of its +action, so a repeated step keeps the same submission idempotency key and +re-attaches instead of creating a duplicate job. ## Controls and Ownership @@ -147,11 +182,13 @@ POST /api/sessions/{session_id}/remote-jobs/{job_id}/terminate Both invoke the provider operation through `RemoteJobService`, update the durable job lifecycle, and append a `user_control` event. They do not cancel the step-executor process. The executor sees this event through -`get_e2b_job_status` and must report `needs_replanning` rather than retrying an -interrupted command or submitting a replacement sandbox. +`get_remote_job_status` and must report `needs_replanning` rather than +retrying an interrupted command or submitting a replacement job. `pause` +returns a 409 (via `CapabilityError`) for a provider that does not support +pausing, such as `bohr_job`. -`terminate_e2b_sandbox` irreversibly releases a sandbox. Agents should collect -or record required output before calling it. +`terminate_remote_job` irreversibly releases a job or sandbox. Agents should +collect or record required output before calling it. ## Storage Scope @@ -160,12 +197,72 @@ the middleware routes each owner to a per-user `.adk/remote-jobs.db` under the user's mounted MatCreator home. This keeps job records, controls, and monitoring isolated by owner and session. +## Provider Plugin Architecture + +Adding a new remote-job provider (a different HPC scheduler, another +sandbox platform, ...) means implementing `RemoteJobAdapter` and registering +it — nothing else in the control plane changes. + +1. **Implement the adapter** (`src/matcreator/control_plane/providers/.py`): + subclass `RemoteJobAdapter` from `providers/base.py` and implement the + three mandatory methods (`create`, `status`, `cancel`). Declare + `provider`, `capabilities` (a `frozenset[RemoteJobCapability]`), and + `poll_interval_seconds` as class attributes. Implement only the optional + methods your capabilities declare: + + | Capability | Optional method(s) | Example provider | + | --- | --- | --- | + | `PAUSE` / `RESUME` | `pause` / `resume` | `e2b` (pause only) | + | `INTERACTIVE_EXEC` | `run_command` | `e2b`, `bohr_sandbox` | + | `FILE_TRANSFER` | `upload_file` / `download_file` | `e2b`, `bohr_sandbox` | + | `BATCH_COLLECT` | `collect_outputs` | `bohr_job` | + + `status` returns a `RemoteJobStatus(normalized_status, snapshot, error)`. + Use `normalized_status=None` when the provider can only confirm liveness + (an interactive sandbox that stays "running" until explicitly stopped); + return one of the canonical statuses from `remote_jobs.py` (e.g. + `"succeeded"`, `"failed"`, `"cancelled"`) when the provider can report an + actual lifecycle observation (a batch job that finishes on its own). + +2. **Register it** in `src/matcreator/control_plane/providers/__init__.py` + with a lazy factory: + ```python + register_adapter("my_provider", lambda: MyProviderAdapter()) + ``` + The factory is not called until the first `get_adapter("my_provider")`, so + registering a provider never forces an optional SDK/CLI import at process + startup. + +3. **(Optional) add a submit tool** in + `src/matcreator/agents/execution_agent/remote_job_tools.py` if the agent + should be able to submit this provider's jobs — submission parameters are + inherently provider-specific (a template vs. a machine type + image), so + this is the one place a new provider needs new code beyond the adapter + itself. Every operation *after* submission + (`get_remote_job_status`/`pause_remote_job`/`terminate_remote_job`/ + `run_remote_job_command`/`upload_remote_job_input`/ + `download_remote_job_output`/`collect_remote_job_outputs`) already works + for any provider without changes, dispatching on the stored `job_id` alone. + +`RemoteJobService` and `RemoteJobMonitor` never import a specific adapter or +branch on a provider name — they resolve the adapter for a job through the +registry (`RemoteJobService.adapter_for`) and check `adapter.capabilities` +before calling an optional method, raising `CapabilityError` with a clear, +provider-attributed message if unsupported (e.g. pausing a `bohr_job`). + ## Operational Notes -- The control plane currently supports E2B sandboxes, although the persistent - store is provider-neutral by design. +- Built-in providers: `e2b` (interactive, via the E2B SDK), `bohr_sandbox` + (interactive, via the `bohr` CLI), and `bohr_job` (batch/HPC-style, via the + `bohr` CLI). The persistent store and service are provider-neutral by + design; see [Provider Plugin Architecture](#provider-plugin-architecture) + to add another. - Commands do not persist command text or output in the remote-job database; only limited operational telemetry is recorded. - A sandbox's configured creation timeout is distinct from the monitoring - interval. The adapter currently passes `timeout=0` to E2B command execution, - leaving command duration unrestricted by this control plane. + interval. The E2B adapter currently passes `timeout=0` to command + execution, leaving command duration unrestricted by this control plane. +- `bohr_job` only supports single-job submission (`bohr job submit`); `bohr + job_group` fan-out (many jobs sharing one group) is a possible future + adapter, not implemented here. + diff --git a/pyproject.toml b/pyproject.toml index a70e5f82..eb6b57be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "phonopy>=2.38.0", "seekpath>=2.1.0", "rdkit>=2025.9.1", - "google-adk>=1.28.0", + "google-adk>=2.0.0", "google-cloud-storage", "lbg>=1.2.29", "litellm>=1.77.4", @@ -29,6 +29,7 @@ dependencies = [ "mp-api", "bcrypt>=4.0.0", "docker>=7.0.0", + "e2b<=2.20.0", "e2b-code-interpreter", "matcraft-kit", "know-do-graph>=0.1.8", diff --git a/src/matcreator/agents/execution_agent/e2b_tools.py b/src/matcreator/agents/execution_agent/e2b_tools.py deleted file mode 100644 index 79fea5de..00000000 --- a/src/matcreator/agents/execution_agent/e2b_tools.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Tracked E2B sandbox tools available to isolated step executors.""" -from __future__ import annotations - -import hashlib -import os -from pathlib import Path -from typing import Any - -from google.adk.tools.tool_context import ToolContext - -from ...control_plane.remote_job_service import E2BConnectionConfig, RemoteJobService -from ...control_plane.remote_jobs import RemoteJobStore -from ...workspace import ADK_DIR -from .recovery import record_remote_job_reference - - -def _service() -> RemoteJobService: - return RemoteJobService(RemoteJobStore(ADK_DIR / "remote-jobs.db")) - - -def _owner_id(tool_context: ToolContext) -> str: - invocation = getattr(tool_context, "_invocation_context", None) - return str(getattr(invocation, "user_id", "") or tool_context.state.get("user_id") or "default") - - -def _node_id(tool_context: ToolContext) -> str: - graph_node = str(tool_context.state.get("_graph_exec_node_id") or "step") - return graph_node.rsplit("__node_", 1)[-1] - - -def _connection() -> E2BConnectionConfig: - # Bohrium E2B endpoint uses bare hex keys; disable SDK format validation - os.environ.setdefault("E2B_VALIDATE_API_KEY", "false") - return E2BConnectionConfig( - api_key=os.environ.get("E2B_API_KEY", ""), - api_url=os.environ.get("E2B_API_URL", ""), - project_id=os.environ.get("BOHRIUM_PROJECT_ID", ""), - template="", - ) - - -def submit_e2b_sandbox( - tool_context: ToolContext, - *, - timeout: int = 7200, - template: str = None, - lifecycle: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Create or reuse a tracked E2B sandbox for the current execution step. - - The configured E2B API key, endpoint, and project ID are used server-side. - Never use shell commands or include credentials in tool inputs. A repeated - call for the same step and template returns the existing sandbox record. - """ - session_id = str(tool_context.state.get("session_id") or "") - if not session_id: - return {"status": "error", "message": "No session_id is available for E2B submission."} - node_id = _node_id(tool_context) - connection = _connection() - if not template: - return { - "status": "error", - "message": "An explicit E2B sandbox template is required. Use 'lbg sdbx template ls -q' to list available templates.", - } - connection = E2BConnectionConfig( - api_key=connection.api_key, - api_url=connection.api_url, - project_id=connection.project_id, - template=template, - ) - identity = f"{session_id}:{node_id}:{connection.template}" - idempotency_key = f"e2b:{hashlib.sha256(identity.encode()).hexdigest()}" - try: - job = _service().submit_e2b( - owner_id=_owner_id(tool_context), - session_id=session_id, - node_id=node_id, - step_number=tool_context.state.get("step_number"), - idempotency_key=idempotency_key, - connection=connection, - timeout=timeout, - lifecycle=lifecycle or {"on_timeout": "pause", "auto_resume": True}, - ) - except Exception as exc: - return {"status": "error", "message": f"E2B submission failed: {exc}"} - record_remote_job_reference( - session_id=session_id, - node_id=node_id, - job_id=job["job_id"], - provider="e2b", - external_id=job["external_id"], - ) - return { - "status": job["status"], - "job_id": job["job_id"], - "sandbox_id": job["external_id"], - "message": "Tracked E2B sandbox is ready. Use its job_id for status or controls.", - } - - -def get_e2b_job_status(job_id: str, tool_context: ToolContext) -> dict[str, Any]: - """Read one tracked E2B job owned by the current session.""" - service = _service() - job = service.store.get_job(job_id) - if job is None or job["owner_id"] != _owner_id(tool_context) or job["session_id"] != tool_context.state.get("session_id"): - return {"status": "error", "message": "E2B job was not found in this session."} - result = {key: job[key] for key in ("job_id", "status", "external_id", "snapshot", "error", "updated_at")} - controls = [ - event["payload"] - for event in service.store.list_events(job_id) - if event["event_type"] == "user_control" - ] - if controls: - result["user_control"] = controls[-1] - return result - - -def pause_e2b_sandbox(job_id: str, tool_context: ToolContext) -> dict[str, Any]: - """Pause a tracked E2B sandbox belonging to the current session.""" - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - try: - paused = _service().pause_e2b(job_id) - except Exception as exc: - return {"status": "error", "message": f"E2B pause failed: {exc}"} - return {"job_id": paused["job_id"], "status": paused["status"], "sandbox_id": paused["external_id"]} - - -def terminate_e2b_sandbox(job_id: str, tool_context: ToolContext) -> dict[str, Any]: - """Terminate a tracked E2B sandbox belonging to the current session.""" - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - try: - terminated = _service().terminate_e2b(job_id) - except Exception as exc: - return {"status": "error", "message": f"E2B termination failed: {exc}"} - return {"job_id": terminated["job_id"], "status": terminated["status"], "sandbox_id": terminated["external_id"]} - - -def run_e2b_command( - job_id: str, - command: str, - tool_context: ToolContext, - user: str = "root", -) -> dict[str, Any]: - """Run one command inside a tracked E2B sandbox in the current session. - - Do not put credentials in ``command``. Command text and output are returned - to the current step but are not persisted in the durable job snapshot. - """ - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - try: - return _service().run_e2b_command(job_id, command, user=user) - except Exception as exc: - current = get_e2b_job_status(job_id, tool_context) - result = {"status": "error", "message": f"E2B command failed: {exc}"} - if current.get("user_control"): - result["user_control"] = current["user_control"] - return result - - -def _resolve_workspace_child( - tool_context: ToolContext, - user_path: str, -) -> tuple[Path | None, str | None]: - """Resolve ``user_path`` against the current workspace, confining it. - - Returns ``(resolved_path, None)`` on success or ``(None, message)`` if the - workspace is unavailable or the path escapes it. Shared by upload (source) - and download (destination) so confinement logic cannot drift between them. - """ - workspace_dir = tool_context.state.get("workspace_dir") - if not workspace_dir: - return None, "No workspace_dir is available for the current step." - workspace = Path(str(workspace_dir)).resolve() - candidate = Path(user_path).expanduser() - candidate = candidate.resolve() if candidate.is_absolute() else (workspace / candidate).resolve() - if not candidate.is_relative_to(workspace): - return None, "Path must resolve inside the current workspace." - return candidate, None - - -def upload_e2b_input( - job_id: str, - source_path: str, - destination_path: str, - tool_context: ToolContext, -) -> dict[str, Any]: - """Upload a workspace input file into a tracked E2B sandbox. - - ``source_path`` must resolve inside the current workspace. Use an absolute - sandbox path for ``destination_path`` such as ``/home/user/input.in``. - """ - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - source, error = _resolve_workspace_child(tool_context, source_path) - if error is not None: - return {"status": "error", "message": f"E2B upload failed: {error}"} - try: - return _service().upload_e2b_file(job_id, source, destination_path) - except Exception as exc: - return {"status": "error", "message": f"E2B upload failed: {exc}"} - - -def download_e2b_output( - job_id: str, - source_path: str, - destination_path: str, - tool_context: ToolContext, -) -> dict[str, Any]: - """Download a file from a tracked E2B sandbox into the local workspace. - - ``source_path`` is an absolute path inside the sandbox (e.g. - ``/home/user/CHGCAR``). ``destination_path`` must resolve inside the - current workspace. Large binary outputs are streamed via the E2B - filesystem API, so they are not truncated by command-output limits. - """ - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - destination, error = _resolve_workspace_child(tool_context, destination_path) - if error is not None: - return {"status": "error", "message": f"E2B download failed: {error}"} - try: - return _service().download_e2b_file(job_id, source_path, destination) - except Exception as exc: - return {"status": "error", "message": f"E2B download failed: {exc}"} \ No newline at end of file diff --git a/src/matcreator/agents/execution_agent/recovery.py b/src/matcreator/agents/execution_agent/recovery.py index 5329b83a..1cab4242 100644 --- a/src/matcreator/agents/execution_agent/recovery.py +++ b/src/matcreator/agents/execution_agent/recovery.py @@ -286,11 +286,16 @@ def _active_remote_job( def remote_job_reference(job: dict[str, Any]) -> dict[str, Any]: """Return the identity subset of a remote job stored on a graph node.""" + snapshot = job.get("snapshot") if isinstance(job.get("snapshot"), dict) else {} return { "job_id": job["job_id"], "provider": job["provider"], "external_id": job["external_id"], "status": job["status"], + # Surfaced so re-attach instructions can tell a fresh executor to poll + # an in-flight background command instead of guessing whether one is + # running or safely re-issuing it (see start_job_command/poll_job_command). + "has_background_command": bool(snapshot.get("background_command")), } diff --git a/src/matcreator/agents/execution_agent/remote_job_tools.py b/src/matcreator/agents/execution_agent/remote_job_tools.py new file mode 100644 index 00000000..06d80a5f --- /dev/null +++ b/src/matcreator/agents/execution_agent/remote_job_tools.py @@ -0,0 +1,545 @@ +"""Remote-job tools available to isolated step executors. + +Submission is provider-specific — an E2B/bohr sandbox needs a template while +a batch job needs a machine type and image, so there is one submit tool per +provider (``submit_e2b_sandbox``, ``submit_bohr_sandbox``, +``submit_bohr_job``). Every operation after submission dispatches on the +``job_id`` alone and works the same for any provider, so adding a new +provider plugin never requires a new post-submission tool here. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any + +from google.adk.tools.tool_context import ToolContext + +from ...control_plane.providers.e2b import E2BConnectionConfig +from ...control_plane.remote_job_service import RemoteJobService +from ...control_plane.remote_jobs import TERMINAL_REMOTE_JOB_STATUSES, RemoteJobStore +from ...workspace import ADK_DIR +from .recovery import record_remote_job_reference + +# Every terminal status except "collected" (the successful end of a batch +# job) means the submission is not usable and must not be reported as ready. +_FAILED_SUBMISSION_STATUSES = TERMINAL_REMOTE_JOB_STATUSES - {"collected"} + + +def _service() -> RemoteJobService: + return RemoteJobService(RemoteJobStore(ADK_DIR / "remote-jobs.db")) + + +def _owner_id(tool_context: ToolContext) -> str: + invocation = getattr(tool_context, "_invocation_context", None) + return str(getattr(invocation, "user_id", "") or tool_context.state.get("user_id") or "default") + + +def _node_id(tool_context: ToolContext) -> str: + graph_node = str(tool_context.state.get("_graph_exec_node_id") or "step") + return graph_node.rsplit("__node_", 1)[-1] + + +def _idempotency_key(session_id: str, node_id: str, discriminator: str) -> str: + identity = f"{session_id}:{node_id}:{discriminator}" + return f"remote-job:{hashlib.sha256(identity.encode()).hexdigest()}" + + +def _submit( + tool_context: ToolContext, + *, + provider: str, + spec: dict[str, Any], + discriminator: str, + persisted_specification: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Shared submission plumbing used by every provider-specific submit tool.""" + session_id = str(tool_context.state.get("session_id") or "") + if not session_id: + return {"status": "error", "message": "No session_id is available for remote-job submission."} + node_id = _node_id(tool_context) + idempotency_key = _idempotency_key(session_id, node_id, discriminator) + try: + job = _service().submit_job( + owner_id=_owner_id(tool_context), + session_id=session_id, + provider=provider, + node_id=node_id, + step_number=tool_context.state.get("step_number"), + idempotency_key=idempotency_key, + spec=spec, + persisted_specification=persisted_specification, + ) + except Exception as exc: + return {"status": "error", "message": f"{provider} submission failed: {exc}"} + record_remote_job_reference( + session_id=session_id, + node_id=node_id, + job_id=job["job_id"], + provider=provider, + external_id=job["external_id"], + ) + return { + "status": job["status"], + "job_id": job["job_id"], + "external_id": job["external_id"], + "error": job.get("error"), + } + + +def _submission_response(result: dict[str, Any], *, id_field: str, success_message: str) -> dict[str, Any]: + """Convert a ``_submit`` result into the tool response, never claiming a + + failed/cancelled/terminated/lost job is ready. The durable record's + ``error`` is surfaced so the caller sees the actual cause. + """ + if result.get("status") == "error": + return result + response = { + "status": result["status"], + "job_id": result["job_id"], + id_field: result["external_id"], + } + if result["status"] in _FAILED_SUBMISSION_STATUSES: + cause = result.get("error") or f"the tracked job is in terminal status '{result['status']}'" + response["message"] = f"Remote job submission is not usable: {cause}" + return response + response["message"] = success_message + return response + + +def _connection() -> E2BConnectionConfig: + # Bohrium E2B endpoint uses bare hex keys; disable SDK format validation + os.environ.setdefault("E2B_VALIDATE_API_KEY", "false") + return E2BConnectionConfig( + api_key=os.environ.get("E2B_API_KEY", ""), + api_url=os.environ.get("E2B_API_URL", ""), + project_id=os.environ.get("BOHRIUM_PROJECT_ID", ""), + template="", + ) + + +def submit_e2b_sandbox( + tool_context: ToolContext, + *, + timeout: int = 7200, + template: str = None, + lifecycle: dict[str, Any] | str | None = None, +) -> dict[str, Any]: + """Create or reuse a tracked E2B sandbox for the current execution step. + + The configured E2B API key, endpoint, and project ID are used server-side. + Never use shell commands or include credentials in tool inputs. A repeated + call for the same step and template returns the existing sandbox record. + """ + session_id = str(tool_context.state.get("session_id") or "") + if not session_id: + return {"status": "error", "message": "No session_id is available for E2B submission."} + if not template: + return { + "status": "error", + "message": "An explicit E2B sandbox template is required. Use 'lbg sdbx template ls -q' to list available templates.", + } + if isinstance(lifecycle, str): + try: + lifecycle = json.loads(lifecycle) + except json.JSONDecodeError: + pass # still a str; rejected below + if lifecycle is not None and not isinstance(lifecycle, dict): + return { + "status": "error", + "message": "lifecycle must be a JSON object such as {\"on_timeout\": \"pause\", \"auto_resume\": true}.", + } + connection = _connection() + missing_config = [ + name + for name, value in ( + ("E2B_API_KEY", connection.api_key), + ("E2B_API_URL", connection.api_url), + ("BOHRIUM_PROJECT_ID", connection.project_id), + ) + if not value + ] + if missing_config: + return { + "status": "error", + "message": ( + f"E2B is not configured on the server: {', '.join(missing_config)} unset. " + "If only the `bohr` CLI is available, use submit_bohr_sandbox instead." + ), + } + connection = E2BConnectionConfig( + api_key=connection.api_key, + api_url=connection.api_url, + project_id=connection.project_id, + template=template, + ) + spec = connection.to_spec_dict(timeout=timeout, lifecycle=lifecycle or {"on_timeout": "pause", "auto_resume": True}) + persisted_specification = {key: value for key, value in spec.items() if key != "api_key"} + result = _submit( + tool_context, + provider="e2b", + spec=spec, + discriminator=template, + persisted_specification=persisted_specification, + ) + return _submission_response( + result, + id_field="sandbox_id", + success_message="Tracked E2B sandbox is ready. Use its job_id for status or controls.", + ) + + +def submit_bohr_sandbox( + tool_context: ToolContext, + *, + project_id: int = None, + template: str = None, + timeout: int = None, + image: str = None, + gpu: str = None, + never_timeout: bool = False, + env: dict[str, str] | None = None, +) -> dict[str, Any]: + """Create or reuse a tracked Bohrium CLI sandbox (`bohr sandbox`) for the current step. + + Both this and `submit_e2b_sandbox` reach the same Bohrium sandbox + platform; use this one when only the `bohr` CLI (not the E2B SDK/API key) + is available in the current environment. An explicit ``template`` is + required (e.g. ``doc-compiler``). ``gpu`` selects a GPU shortcut template + (``4090``/``5090``/``l20``). Falls back to the `BOHRIUM_PROJECT_ID` + environment variable if ``project_id`` is omitted. + """ + resolved_project_id = project_id or os.environ.get("BOHRIUM_PROJECT_ID", "") + if not resolved_project_id: + return {"status": "error", "message": "An explicit project_id is required for a bohr sandbox."} + if not template: + return { + "status": "error", + "message": ( + "An explicit sandbox template is required (e.g. 'doc-compiler'). " + "Use 'bohr sandbox template list' to see available templates." + ), + } + spec = { + "project_id": resolved_project_id, + "template": template, + "timeout": timeout, + "image": image, + "gpu": gpu, + "never_timeout": never_timeout, + "env": env or {}, + } + result = _submit( + tool_context, + provider="bohr_sandbox", + spec=spec, + discriminator=template, + ) + return _submission_response( + result, + id_field="sandbox_id", + success_message="Tracked bohr sandbox is ready. Use its job_id for status or controls.", + ) + + +def submit_bohr_job( + tool_context: ToolContext, + *, + project_id: int = None, + job_name: str = None, + machine_type: str = None, + image_address: str = None, + command: str = None, + input_directory: str | None = None, + result_path: str | None = None, + max_run_time: int | None = None, +) -> dict[str, Any]: + """Submit a batch/HPC-style Bohrium job (`bohr job submit`) for the current step. + + This is a fire-and-forget batch submission, not an interactive sandbox: + inputs are staged once via ``input_directory`` and there is no + `run_remote_job_command` for this provider — the whole computation must + be expressed in ``command``. Poll `get_remote_job_status` until it + reports ``succeeded``, then call `collect_remote_job_outputs`. + """ + resolved_project_id = project_id or os.environ.get("BOHRIUM_PROJECT_ID", "") + missing = [ + name + for name, value in ( + ("project_id", resolved_project_id), + ("job_name", job_name), + ("machine_type", machine_type), + ("image_address", image_address), + ("command", command), + ) + if not value + ] + if missing: + return { + "status": "error", + "message": f"Missing required field(s) for bohr job submission: {', '.join(missing)}", + } + spec = { + "project_id": resolved_project_id, + "job_name": job_name, + "machine_type": machine_type, + "image_address": image_address, + "command": command, + "input_directory": input_directory, + "result_path": result_path, + "max_run_time": max_run_time, + } + result = _submit( + tool_context, + provider="bohr_job", + spec=spec, + discriminator=f"{job_name}:{machine_type}:{image_address}", + ) + return _submission_response( + result, + id_field="bohr_job_id", + success_message=( + "Tracked bohr batch job is submitted. Poll get_remote_job_status until it " + "reports succeeded, then call collect_remote_job_outputs." + ), + ) + + +def get_remote_job_status(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Read one tracked remote job (any provider) owned by the current session.""" + service = _service() + job = service.store.get_job(job_id) + if ( + job is None + or job["owner_id"] != _owner_id(tool_context) + or job["session_id"] != tool_context.state.get("session_id") + ): + return {"status": "error", "message": "Remote job was not found in this session."} + result = { + key: job[key] for key in ("job_id", "provider", "status", "external_id", "snapshot", "error", "updated_at") + } + controls = [ + event["payload"] for event in service.store.list_events(job_id) if event["event_type"] == "user_control" + ] + if controls: + result["user_control"] = controls[-1] + return result + + +def pause_remote_job(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Pause a tracked remote job belonging to the current session. + + Returns an error if the job's provider does not support pausing (e.g. a + batch job); terminate it instead if it must stop. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + paused = _service().pause_job(job_id) + except Exception as exc: + return {"status": "error", "message": f"Pause failed: {exc}"} + return {"job_id": paused["job_id"], "status": paused["status"], "external_id": paused["external_id"]} + + +def terminate_remote_job(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Terminate a tracked remote job belonging to the current session.""" + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + terminated = _service().terminate_job(job_id) + except Exception as exc: + return {"status": "error", "message": f"Termination failed: {exc}"} + return {"job_id": terminated["job_id"], "status": terminated["status"], "external_id": terminated["external_id"]} + + +def run_remote_job_command( + job_id: str, + command: str, + tool_context: ToolContext, + user: str = "root", +) -> dict[str, Any]: + """Run one short command inside a tracked interactive remote job (e.g. a sandbox). + + This BLOCKS until the command finishes, with no timeout of its own. Only + use it for commands expected to finish in well under a minute (checking a + file, `mkdir`, `grep`, listing a directory, ...). For anything that might + run longer — a training run, a `vasp_std`/`mpirun` invocation, any real + computation — use `start_remote_job_command` + `poll_remote_job_command` + instead: those never block longer than one quick status check and the + command survives this process restarting or losing connection, unlike a + long blocking call here which has no way to recover if interrupted. + + Do not put credentials in ``command``. Command text and output are + returned to the current step but are not persisted in the durable job + snapshot. Not every provider supports this — a batch job (e.g. + `bohr_job`) returns an error explaining that its whole command must run + at submission time instead. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + return _service().run_job_command(job_id, command, user=user) + except Exception as exc: + current = get_remote_job_status(job_id, tool_context) + result = {"status": "error", "message": f"Remote command failed: {exc}"} + if current.get("user_control"): + result["user_control"] = current["user_control"] + return result + + +def start_remote_job_command( + job_id: str, + command: str, + tool_context: ToolContext, + user: str = "root", +) -> dict[str, Any]: + """Launch a long-running command inside a tracked interactive remote job WITHOUT blocking. + + Use this instead of `run_remote_job_command` for any real computation + (training, `vasp_std`/`mpirun`, anything that might take more than a + minute). Returns almost immediately once the command is launched in the + background; call `poll_remote_job_command` with the same `job_id` + afterward — repeatedly, across as many separate tool calls or even + separate step-executor attempts as needed — to check whether it has + finished. The command's progress is tracked durably on the job itself, so + re-attaching to this `job_id` after a step timeout, a crash, or a lost + connection always finds the same in-flight command rather than losing + track of it or risking a duplicate run. + + There is at most one in-flight background command per job; starting a + new one before polling the previous one to completion overwrites the + previous command's tracked handle. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + return _service().start_job_command(job_id, command, user=user) + except Exception as exc: + return {"status": "error", "message": f"Failed to start remote command: {exc}"} + + +def poll_remote_job_command(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Check on the job's most recently started background command. + + Returns `{"running": true, ...}` if it is still executing — call this + again later (e.g. after doing other work, or in a fresh step-executor + attempt after re-attaching via `get_remote_job_status`) rather than + waiting in a tight loop. Once finished, returns `{"running": false, + "exit_code": ..., "output_tail": ...}`; `output_tail` is only the last + portion of combined stdout/stderr — for the full output of a long run, + use `download_remote_job_output` on the returned `log_path`. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + return _service().poll_job_command(job_id) + except Exception as exc: + return {"status": "error", "message": f"Failed to poll remote command: {exc}"} + + +def _resolve_workspace_child( + tool_context: ToolContext, + user_path: str, +) -> tuple[Path | None, str | None]: + """Resolve ``user_path`` against the current workspace, confining it. + + Returns ``(resolved_path, None)`` on success or ``(None, message)`` if the + workspace is unavailable or the path escapes it. Shared by upload (source) + and download (destination) so confinement logic cannot drift between them. + """ + workspace_dir = tool_context.state.get("workspace_dir") + if not workspace_dir: + return None, "No workspace_dir is available for the current step." + workspace = Path(str(workspace_dir)).resolve() + candidate = Path(user_path).expanduser() + candidate = candidate.resolve() if candidate.is_absolute() else (workspace / candidate).resolve() + if not candidate.is_relative_to(workspace): + return None, "Path must resolve inside the current workspace." + return candidate, None + + +def upload_remote_job_input( + job_id: str, + source_path: str, + destination_path: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Upload a workspace input file into a tracked interactive remote job. + + ``source_path`` must resolve inside the current workspace. Use an + absolute remote path for ``destination_path`` such as + ``/home/user/input.in``. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + source, error = _resolve_workspace_child(tool_context, source_path) + if error is not None: + return {"status": "error", "message": f"Upload failed: {error}"} + try: + return _service().upload_job_file(job_id, source, destination_path) + except Exception as exc: + return {"status": "error", "message": f"Upload failed: {exc}"} + + +def download_remote_job_output( + job_id: str, + source_path: str, + destination_path: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Download a file from a tracked interactive remote job into the local workspace. + + ``source_path`` is an absolute path on the remote side (e.g. + ``/home/user/CHGCAR``). ``destination_path`` must resolve inside the + current workspace. For a batch job (e.g. `bohr_job`), use + `collect_remote_job_outputs` instead once the job has succeeded. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + destination, error = _resolve_workspace_child(tool_context, destination_path) + if error is not None: + return {"status": "error", "message": f"Download failed: {error}"} + try: + return _service().download_job_file(job_id, source_path, destination) + except Exception as exc: + return {"status": "error", "message": f"Download failed: {exc}"} + + +def collect_remote_job_outputs( + job_id: str, + destination_path: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Pull a finished batch job's declared output files into the local workspace. + + Only valid once `get_remote_job_status` reports ``status: succeeded``. + ``destination_path`` must resolve inside the current workspace as a + directory. A repeated call after outputs are already collected is a + durable no-op that returns the same artifact list rather than + downloading twice. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + destination, error = _resolve_workspace_child(tool_context, destination_path) + if error is not None: + return {"status": "error", "message": f"Output collection failed: {error}"} + try: + collected = _service().collect_job_outputs(job_id, destination) + except Exception as exc: + return {"status": "error", "message": f"Output collection failed: {exc}"} + return { + "job_id": collected["job_id"], + "status": collected["status"], + "artifacts": collected.get("artifacts", []), + } diff --git a/src/matcreator/agents/execution_agent/step_executor.py b/src/matcreator/agents/execution_agent/step_executor.py index 2e822f6c..a4c67a30 100644 --- a/src/matcreator/agents/execution_agent/step_executor.py +++ b/src/matcreator/agents/execution_agent/step_executor.py @@ -18,14 +18,19 @@ from ...tools.remoteagent_tool import load_remote_a2a_agents from ...tools.util_tools import show_artifact, show_plot, show_structure from ...tools.workspace_tools import get_user_skills_root, run_bash, run_python -from .e2b_tools import ( - download_e2b_output, - get_e2b_job_status, - pause_e2b_sandbox, - run_e2b_command, +from .remote_job_tools import ( + collect_remote_job_outputs, + download_remote_job_output, + get_remote_job_status, + pause_remote_job, + poll_remote_job_command, + run_remote_job_command, + start_remote_job_command, + submit_bohr_job, + submit_bohr_sandbox, submit_e2b_sandbox, - terminate_e2b_sandbox, - upload_e2b_input, + terminate_remote_job, + upload_remote_job_input, ) logger = logging.getLogger(__name__) @@ -135,20 +140,48 @@ def _fill_missing_fields(self) -> "StepExecutorResult": 3. **If submission.json exists but outputs are missing**, reuse the same submission file (dpdispatcher is idempotent — it skips completed tasks). Do NOT regenerate submission.json. 4. **Never resubmit a job that already completed** — this wastes GPU time and creates duplicate training runs. -## Re-attaching to an existing E2B job (CRITICAL) -If your `prior_context` contains "REMOTE JOB ALREADY SUBMITTED", a tracked sandbox job -for this exact step is already running: -1. Call `get_e2b_job_status` with the given `job_id` FIRST. -2. NEVER call `submit_e2b_sandbox` for that step — it would duplicate a running job. -3. If the job finished, collect its outputs with `download_e2b_output` and report success. -4. If it is still running, call `submit_step_result(status="needs_replanning", ...)` stating +## Re-attaching to an existing remote job (CRITICAL) +If your `prior_context` contains "REMOTE JOB ALREADY SUBMITTED", a tracked remote job +(sandbox or batch job) for this exact step is already running: +1. Call `get_remote_job_status` with the given `job_id` FIRST. +2. NEVER call `submit_e2b_sandbox`, `submit_bohr_sandbox`, or `submit_bohr_job` for that + step — it would duplicate a running job. +3. If its `snapshot` contains `background_command`, a command is (or was) running in the + background — call `poll_remote_job_command` FIRST rather than issuing a new command. Never + call `start_remote_job_command`/`run_remote_job_command` again for the same computation + just because you lost track of it; re-running a non-idempotent command (e.g. a training + run) can corrupt output or double-charge compute. +4. If the job finished (`status: succeeded`), collect its outputs — `download_remote_job_output` + for an interactive sandbox, or `collect_remote_job_outputs` for a batch job — and report success. +5. If it is still running, call `submit_step_result(status="needs_replanning", ...)` stating that the job has not finished yet and quoting its job_id and status. -## User controls for E2B sandboxes -`get_e2b_job_status` may return `user_control` when the user paused or terminated -the sandbox from the UI. This does not cancel your executor. Treat it as the -user's explicit instruction: do not retry the interrupted sandbox command or -submit a replacement sandbox. Report the pause or termination accurately with +## Choosing a remote-job submit tool +- `submit_e2b_sandbox`: interactive E2B sandbox (default choice when the E2B SDK/API key + is configured). +- `submit_bohr_sandbox`: interactive sandbox via the `bohr` CLI — use only when the E2B + path is unavailable; both reach the same Bohrium sandbox platform. +- `submit_bohr_job`: batch/HPC-style job via `bohr job submit`. There is no interactive + command execution for this provider — express the entire computation in `command`, then + poll `get_remote_job_status` until `succeeded` and call `collect_remote_job_outputs`. + +## Running commands inside a sandbox: blocking vs. background (CRITICAL) +- `run_remote_job_command` BLOCKS until the command finishes, with no timeout. Only use it + for short commands expected to finish in well under a minute (`mkdir`, `grep`, checking a + file, listing a directory). +- For any real computation (a training run, `vasp_std`/`mpirun`, anything that might run + longer than a minute), use `start_remote_job_command` instead — it launches the command in + the background and returns almost immediately — then call `poll_remote_job_command` to check + on it. Do useful work in between polls (e.g. `submit_step_result(status="needs_replanning", ...)` + reporting the job is still running, rather than looping tool calls back-to-back) so a single + step doesn't sit blocked. Re-attaching to the same `job_id` later always finds the same + tracked command via `poll_remote_job_command`, so it's always safe even after a step timeout. + +## User controls for remote jobs +`get_remote_job_status` may return `user_control` when the user paused or terminated +the job from the UI. This does not cancel your executor. Treat it as the +user's explicit instruction: do not retry the interrupted command or +submit a replacement job. Report the pause or termination accurately with `submit_step_result(status="needs_replanning", replan_reason=...)`. ## MANDATORY: Always call submit_step_result @@ -264,12 +297,17 @@ def build_step_executor_agent(llm_card: LLMCard) -> LlmAgent: FunctionTool(run_python), FunctionTool(run_bash), FunctionTool(submit_e2b_sandbox), - FunctionTool(get_e2b_job_status), - FunctionTool(run_e2b_command), - FunctionTool(upload_e2b_input), - FunctionTool(download_e2b_output), - FunctionTool(pause_e2b_sandbox), - FunctionTool(terminate_e2b_sandbox), + FunctionTool(submit_bohr_sandbox), + FunctionTool(submit_bohr_job), + FunctionTool(get_remote_job_status), + FunctionTool(run_remote_job_command), + FunctionTool(start_remote_job_command), + FunctionTool(poll_remote_job_command), + FunctionTool(upload_remote_job_input), + FunctionTool(download_remote_job_output), + FunctionTool(collect_remote_job_outputs), + FunctionTool(pause_remote_job), + FunctionTool(terminate_remote_job), ALL_SKILLS_TOOLSET, FunctionTool(show_plot), FunctionTool(show_structure), diff --git a/src/matcreator/agents/execution_agent/step_executor_runner.py b/src/matcreator/agents/execution_agent/step_executor_runner.py index cd79b3f6..763c8233 100644 --- a/src/matcreator/agents/execution_agent/step_executor_runner.py +++ b/src/matcreator/agents/execution_agent/step_executor_runner.py @@ -389,13 +389,20 @@ def _remote_job_prior_context(tool_context: ToolContext, node_id: Optional[str]) remote_job = node.get("remote_job") if not isinstance(remote_job, dict) or not remote_job.get("job_id"): return None + background_note = ( + " A background command may still be in flight — call poll_remote_job_command " + "before starting a new one." + if remote_job.get("has_background_command") + else "" + ) return ( "REMOTE JOB ALREADY SUBMITTED for this step: " f"job_id={remote_job['job_id']} provider={remote_job.get('provider')} " - f"sandbox_id={remote_job.get('external_id')} last_known_status={remote_job.get('status')}. " - "Call get_e2b_job_status with this job_id to re-attach. Do NOT call submit_e2b_sandbox " - "again for this step — that job is still tracked and must not be duplicated. If it is " - "still running, report needs_replanning explaining that the job has not finished yet." + f"external_id={remote_job.get('external_id')} last_known_status={remote_job.get('status')}. " + "Call get_remote_job_status with this job_id to re-attach. Do NOT call submit_e2b_sandbox, " + "submit_bohr_sandbox, or submit_bohr_job again for this step — that job is still tracked " + "and must not be duplicated. If it is still running, report needs_replanning explaining " + f"that the job has not finished yet.{background_note}" ) diff --git a/src/matcreator/agents/graph_logger.py b/src/matcreator/agents/graph_logger.py index 8e5c83a5..c2f75ca0 100644 --- a/src/matcreator/agents/graph_logger.py +++ b/src/matcreator/agents/graph_logger.py @@ -14,6 +14,7 @@ "label": "", "status": "idle|running|success|failed|needs_replanning", "parent_id": "", + "batch_id": "", "start_time": "", "end_time": "", "summary": "", @@ -73,6 +74,7 @@ def log_node_start( node_type: NodeType, label: str, parent_id: Optional[str] = None, + batch_id: Optional[str] = None, ) -> None: """Create or overwrite a node with status=running.""" with self._lock: @@ -84,6 +86,10 @@ def log_node_start( "label": label, "status": "running", "parent_id": parent_id, + # Execution producers may share this value for work launched + # by one planning round. It is presentation metadata only: the + # actual parent edge remains parent_id -> node_id. + "batch_id": batch_id if batch_id is not None else (existing or {}).get("batch_id"), "start_time": existing["start_time"] if existing else _now(), "end_time": None, "summary": None, @@ -130,6 +136,11 @@ def count_nodes_of_type(self, node_type: NodeType) -> int: graph = self._read() return sum(1 for n in graph["nodes"].values() if n["type"] == node_type) + def nodes_of_type(self, node_type: NodeType) -> list[dict]: + """Return graph nodes of a type for lifecycle-aware node reuse.""" + graph = self._read() + return [node for node in graph["nodes"].values() if node.get("type") == node_type] + def log_node_input(self, node_id: str, input_data: dict) -> None: """Store the structured input that was passed to the sub-agent.""" with self._lock: @@ -161,13 +172,31 @@ def log_state_delta(self, node_id: str, delta: dict) -> None: self._write(graph) def log_conversation_event(self, node_id: str, entry: dict) -> None: - """Append one conversation turn to the node's conversation list.""" + """Append one conversation turn, coalescing adjacent streamed text. + + LLM providers may send either a token delta or the entire response so + far. Keeping every chunk made the graph noisy and meant the frontend + could not present a single live response for a running node. + """ with self._lock: graph = self._read() node = graph["nodes"].get(node_id) if node is None: return - node.setdefault("conversation", []).append(entry) + conversation = node.setdefault("conversation", []) + previous = conversation[-1] if conversation else None + if ( + previous + and entry.get("type") in {"text", "thought"} + and previous.get("type") == entry.get("type") + and previous.get("author") == entry.get("author") + and isinstance(previous.get("content"), str) + and isinstance(entry.get("content"), str) + ): + previous["content"] = _merge_streamed_text(previous["content"], entry["content"]) + previous["timestamp"] = entry.get("timestamp", previous.get("timestamp")) + else: + conversation.append(entry) self._write(graph) def mark_running_nodes_cancelled( @@ -251,3 +280,15 @@ def _read(self) -> dict: def _write(self, graph: dict) -> None: graph["updated_at"] = _now() self._path.write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _merge_streamed_text(current: str, incoming: str) -> str: + """Merge a streamed delta or cumulative snapshot without duplicated text.""" + if not incoming or current.endswith(incoming): + return current + if incoming.startswith(current): + return incoming + for overlap in range(min(len(current), len(incoming)), 0, -1): + if current.endswith(incoming[:overlap]): + return current + incoming[overlap:] + return current + incoming diff --git a/src/matcreator/agents/orchestrator/agent.py b/src/matcreator/agents/orchestrator/agent.py index e896af00..f95b65af 100644 --- a/src/matcreator/agents/orchestrator/agent.py +++ b/src/matcreator/agents/orchestrator/agent.py @@ -43,6 +43,7 @@ _DEFAULT_MEMORIZATION_FREQUENCY = 1 _DEFAULT_REVIEW_FREQUENCY = 10 +_PLANNING_NODE_STATE_KEY = "_graph_planning_node_id" # --------------------------------------------------------------------------- @@ -78,6 +79,36 @@ def _is_graph_complete(state: dict) -> bool: return bool(nodes) and all(n.get("status") == "success" for n in nodes.values()) +def _get_planning_node_id(state: dict, graph: AgentGraphLogger) -> str: + """Return the graph node representing the session's current planner. + + The orchestrator keeps one planning-agent instance for the session. A + planning invocation is therefore activity on that agent, not evidence that + a new agent was created. Persisting its graph id in session state keeps + execution/replanning loops attached to one node. A future code path that + deliberately replaces the planning agent can clear this state key before + creating its replacement node. + """ + node_id = state.get(_PLANNING_NODE_STATE_KEY) + if isinstance(node_id, str) and node_id: + return node_id + + # Preserve the original planner node when resuming a graph written before + # this state key existed, rather than adding another planning node. + existing_planners = [ + node for node in graph.nodes_of_type("planning") + if isinstance(node.get("id"), str) + ] + if existing_planners: + existing_planners.sort(key=lambda node: (node.get("start_time") or "", node["id"])) + node_id = existing_planners[0]["id"] + else: + node_id = "planning_0" + + state[_PLANNING_NODE_STATE_KEY] = node_id + return node_id + + # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- @@ -125,14 +156,13 @@ async def _run_async_impl( logger.warning("[orchestrator] recovered execution state: %s", recovered) loop_idx = graph.count_nodes_of_type("execution") + planning_id = _get_planning_node_id(state, graph) while True: # ── Planning phase (always runs first) ─────────────────────────── state["execution_approved"] = False - has_execution = loop_idx > 0 - planning_id = f"planning_{loop_idx}" if has_execution else "planning_0" logger.info("[orchestrator] entering planning phase") - graph.log_node_start(planning_id, "planning", f"Planning {loop_idx + 1}", "orchestrator") + graph.log_node_start(planning_id, "planning", "Planning", "orchestrator") # Approval is a hard handoff boundary. Yield the successful tool # response first so clients can persist/render it, then close the # planner stream before it can start another model/tool round. @@ -171,7 +201,16 @@ async def _run_async_impl( ) exec_id = f"execution_{loop_idx}" - graph.log_node_start(exec_id, "execution", f"Execution {loop_idx + 1}", "orchestrator") + graph.log_node_start( + exec_id, + "execution", + f"Execution {loop_idx + 1}", + planning_id, + # This remains metadata: all execution nodes continue to + # point directly at the original planner. The frontend + # uses it to place one planning round on one vine layer. + batch_id=f"{planning_id}:round:{loop_idx}", + ) state["_graph_exec_node_id"] = exec_id async for event in self.execution_agent.run_async(ctx): diff --git a/src/matcreator/agents/thinking_agent/agent.py b/src/matcreator/agents/thinking_agent/agent.py index ac5a5e53..68de5592 100644 --- a/src/matcreator/agents/thinking_agent/agent.py +++ b/src/matcreator/agents/thinking_agent/agent.py @@ -273,8 +273,8 @@ async def run_flash_step( its timeout while the step's `remote_job` is still running on the provider. Report the job identity to the user and stop. Do NOT re-run the step to "restart" the job. To check on it later, call `run_flash_step` again with the SAME `label` and an action - that says to call `get_e2b_job_status` with that job_id and collect results if finished — - never to submit a new sandbox. + that says to call `get_remote_job_status` with that job_id and collect results if finished — + never to submit a new job. """ _NORMAL_INSTRUCTION = """ diff --git a/src/matcreator/control_plane/providers/__init__.py b/src/matcreator/control_plane/providers/__init__.py new file mode 100644 index 00000000..41793365 --- /dev/null +++ b/src/matcreator/control_plane/providers/__init__.py @@ -0,0 +1,48 @@ +"""Built-in remote-job provider adapters. + +Importing this package registers every built-in adapter. Adding a new +provider means adding one adapter module and one ``register_adapter`` call +below — nothing else in the control plane needs to change. +""" +from __future__ import annotations + +from .base import CapabilityError, RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus +from .registry import get_adapter, register_adapter, registered_providers, reset_registry + + +def _register_builtin_adapters() -> None: + # Registered as lazy factories (not imported eagerly) so importing this + # package never pays for an adapter's own imports (SDK modules, env-var + # setup) until `get_adapter(...)` actually constructs one. + def _e2b_factory(): + from .e2b import E2BSandboxAdapter + + return E2BSandboxAdapter() + + def _bohr_sandbox_factory(): + from .bohr_sandbox import BohrSandboxAdapter + + return BohrSandboxAdapter() + + def _bohr_job_factory(): + from .bohr_job import BohrJobAdapter + + return BohrJobAdapter() + + register_adapter("e2b", _e2b_factory) + register_adapter("bohr_sandbox", _bohr_sandbox_factory) + register_adapter("bohr_job", _bohr_job_factory) + + +_register_builtin_adapters() + +__all__ = [ + "RemoteJobAdapter", + "RemoteJobCapability", + "RemoteJobStatus", + "CapabilityError", + "get_adapter", + "register_adapter", + "registered_providers", + "reset_registry", +] diff --git a/src/matcreator/control_plane/providers/_bohr_cli.py b/src/matcreator/control_plane/providers/_bohr_cli.py new file mode 100644 index 00000000..f9c1c369 --- /dev/null +++ b/src/matcreator/control_plane/providers/_bohr_cli.py @@ -0,0 +1,75 @@ +"""Shared subprocess boundary for `bohr`-CLI-backed provider adapters. + +Both ``bohr_sandbox`` (interactive) and ``bohr_job`` (batch) adapters shell +out to the same ``bohr`` binary and expect the same JSON envelope +(``{"ok": bool, "data": ..., "error": {...}}``), so the invocation and +error-handling logic lives here once instead of being duplicated per adapter. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from typing import Any + + +class BohrCLIError(RuntimeError): + """Raised when a `bohr` CLI invocation fails or returns unusable output.""" + + +def resolve_bohr_binary() -> str: + """Resolve the `bohr` executable, honoring an explicit override.""" + return os.environ.get("BOHR_CLI_PATH") or shutil.which("bohr") or "bohr" + + +def run_bohr_json(args: list[str], *, timeout: float | None = 120) -> Any: + """Run one `bohr` CLI invocation and return its parsed ``data`` payload. + + Every invocation appends ``-o json --no-interactive -y`` so output is + machine-parseable and no command blocks on an interactive confirmation + prompt. Raises :class:`BohrCLIError` with the CLI's own error message on + failure, so callers never need to parse stderr or exit codes themselves. + """ + command = [resolve_bohr_binary(), *args, "-o", "json", "--no-interactive", "-y"] + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise BohrCLIError("The 'bohr' CLI is not installed or not on PATH") from exc + except subprocess.TimeoutExpired as exc: + raise BohrCLIError(f"bohr {' '.join(args)} timed out after {timeout}s") from exc + + stdout = (completed.stdout or "").strip() + if not stdout: + message = (completed.stderr or "").strip() + raise BohrCLIError( + message or f"bohr {' '.join(args)} produced no output (exit {completed.returncode})" + ) + try: + payload = json.loads(stdout) + except json.JSONDecodeError as exc: + raise BohrCLIError( + f"bohr {' '.join(args)} returned non-JSON output: {stdout[:500]}" + ) from exc + + if not isinstance(payload, dict) or not payload.get("ok", False): + error = (payload or {}).get("error") if isinstance(payload, dict) else None + message = (error or {}).get("message") if isinstance(error, dict) else None + raise BohrCLIError(message or f"bohr {' '.join(args)} failed") + return payload.get("data") + + +def extract_id(data: Any, keys: tuple[str, ...]) -> str | None: + """Return the first present, truthy value among ``keys`` in a dict payload.""" + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if value: + return str(value) + return None diff --git a/src/matcreator/control_plane/providers/base.py b/src/matcreator/control_plane/providers/base.py new file mode 100644 index 00000000..45edd487 --- /dev/null +++ b/src/matcreator/control_plane/providers/base.py @@ -0,0 +1,126 @@ +"""Provider-neutral adapter protocol for the remote-job control plane. + +``RemoteJobStore`` and ``RemoteJobService`` are already provider-neutral (see +``remote_jobs.py``): the persisted schema, lifecycle state machine, and +recovery bookkeeping all key off a generic ``provider`` string. This module +defines the boundary a *new* provider must implement so that +``RemoteJobService`` never needs provider-specific branches — adding a +provider means adding one adapter module plus one registration call (see +``registry.py``), nothing else in the control plane changes. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + + +class RemoteJobCapability(Enum): + """Optional operations a provider adapter may support. + + ``create``/``status``/``cancel`` are mandatory for every adapter (they are + abstract methods on :class:`RemoteJobAdapter`). Everything else is gated + by a capability flag so :class:`RemoteJobService` can reject an + unsupported operation with a clear, provider-attributed error instead of + an ``AttributeError`` surfacing from deep inside an adapter. + """ + + PAUSE = "pause" + RESUME = "resume" + INTERACTIVE_EXEC = "interactive_exec" + FILE_TRANSFER = "file_transfer" + BATCH_COLLECT = "batch_collect" + + +class CapabilityError(NotImplementedError): + """Raised when a requested operation is not supported by a provider.""" + + def __init__(self, provider: str, capability: RemoteJobCapability) -> None: + super().__init__(f"Provider '{provider}' does not support '{capability.value}'") + self.provider = provider + self.capability = capability + + +@dataclass(frozen=True) +class RemoteJobStatus: + """Result of probing one external job. + + ``normalized_status`` must be one of the canonical statuses defined in + ``remote_jobs.py`` (for example ``running``, ``succeeded``, ``failed``, + ``cancelled``, ``lost``) when the provider can report a lifecycle + observation, or ``None`` when the provider can only confirm liveness + without knowing whether that changes the normalized lifecycle (e.g. an + interactive sandbox that stays "running" until an agent explicitly ends + it). ``RemoteJobService`` only transitions the durable record's status + when ``normalized_status`` is not ``None`` and differs from the job's + current status; otherwise it merges ``snapshot`` as a non-lifecycle + observation. + """ + + normalized_status: str | None + snapshot: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + +class RemoteJobAdapter(ABC): + """Boundary between the control plane and one external job provider. + + Subclasses implement only what their provider can actually do. Declaring + ``capabilities`` tells :class:`RemoteJobService` which of the optional + methods below are safe to call; the default implementations below raise + :class:`CapabilityError` as a safety net for a capability that was + declared but never overridden. + """ + + provider: str + capabilities: frozenset[RemoteJobCapability] = frozenset() + # Interval RemoteJobMonitor should wait between reconciliations of jobs + # owned by this provider. Batch/HPC-style providers whose status only + # changes on the order of minutes can declare a much longer interval than + # an interactively addressable sandbox. + poll_interval_seconds: float = 15.0 + + @abstractmethod + def create(self, spec: dict[str, Any]) -> str: + """Create one external job/sandbox and return its provider-side ID.""" + + @abstractmethod + def status(self, external_id: str) -> RemoteJobStatus: + """Probe one external job for liveness and/or lifecycle status.""" + + @abstractmethod + def cancel(self, external_id: str) -> None: + """Irreversibly stop/delete one external job.""" + + def pause(self, external_id: str) -> None: + raise CapabilityError(self.provider, RemoteJobCapability.PAUSE) + + def resume(self, external_id: str) -> None: + raise CapabilityError(self.provider, RemoteJobCapability.RESUME) + + def run_command(self, external_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + raise CapabilityError(self.provider, RemoteJobCapability.INTERACTIVE_EXEC) + + def upload_file(self, external_id: str, source: str | Path, destination: str) -> None: + raise CapabilityError(self.provider, RemoteJobCapability.FILE_TRANSFER) + + def download_file( + self, + external_id: str, + source: str, + destination: str | Path, + *, + user: str | None = None, + ) -> Path: + raise CapabilityError(self.provider, RemoteJobCapability.FILE_TRANSFER) + + def collect_outputs(self, external_id: str, destination_dir: str | Path) -> list[dict[str, Any]]: + """Pull a finished batch job's declared output files to ``destination_dir``. + + Returns a list of ``{"source": ..., "destination": ...}`` records + describing what was collected, so the caller can persist them as job + artifacts. + """ + raise CapabilityError(self.provider, RemoteJobCapability.BATCH_COLLECT) diff --git a/src/matcreator/control_plane/providers/bohr_job.py b/src/matcreator/control_plane/providers/bohr_job.py new file mode 100644 index 00000000..ddc64822 --- /dev/null +++ b/src/matcreator/control_plane/providers/bohr_job.py @@ -0,0 +1,109 @@ +"""Batch/HPC-style adapter over `bohr job` (submit/describe/download/terminate). + +Inputs are staged once at submission time (``--input_directory``); there is +no interactive exec or incremental file transfer, matching how HPC batch +schedulers work (submit, poll a queue, collect outputs once terminal). Only +single-job submission is supported here — `bohr job_group` fan-out (multiple +jobs sharing one group) is a possible future adapter, not this one. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ._bohr_cli import BohrCLIError, extract_id, run_bohr_json +from .base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus + +# `bohr job list`/`describe` report a lowercase `phase` string. Map it onto +# the canonical statuses defined in remote_jobs.py. Any phase not listed here +# (e.g. a future platform phase) leaves normalized_status as None so the +# service records an observation instead of guessing a lifecycle transition. +_PHASE_TO_NORMALIZED = { + "pending": "queued", + "scheduling": "queued", + "running": "running", + "completed": "succeeded", + "failed": "failed", + # "stopped" is the phase used for a job terminated by the user (see + # `bohr job terminate`); "cancelled" is the closest existing canonical + # status for a non-failure, user-initiated stop. + "stopped": "cancelled", +} + +_REQUIRED_SPEC_FIELDS = ("project_id", "job_name", "machine_type", "image_address", "command") + + +class BohrJobAdapter(RemoteJobAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + # Batch job status changes on the order of minutes, not seconds; poll far + # less often than an interactive sandbox to avoid hammering the platform. + poll_interval_seconds = 60.0 + + def create(self, spec: dict[str, Any]) -> str: + missing = [name for name in _REQUIRED_SPEC_FIELDS if not spec.get(name)] + if missing: + raise ValueError(f"bohr_job spec is missing required field(s): {', '.join(missing)}") + args = [ + "job", + "submit", + "--project_id", + str(spec["project_id"]), + "--job_name", + str(spec["job_name"]), + "--machine_type", + str(spec["machine_type"]), + "--image_address", + str(spec["image_address"]), + "--command", + str(spec["command"]), + ] + if spec.get("input_directory"): + args += ["--input_directory", str(spec["input_directory"])] + if spec.get("log_file"): + args += ["--log_file", str(spec["log_file"])] + if spec.get("result_path"): + args += ["--result_path", str(spec["result_path"])] + if spec.get("max_run_time"): + args += ["--max_run_time", str(spec["max_run_time"])] + if spec.get("nnode"): + args += ["--nnode", str(spec["nnode"])] + if spec.get("max_reschedule_times") is not None: + args += ["--max_reschedule_times", str(spec["max_reschedule_times"])] + if spec.get("job_group_id"): + args += ["--job_group_id", str(spec["job_group_id"])] + + data = run_bohr_json(args) + bohr_id = extract_id(data, ("bohrId", "bohr_id", "bohrID", "id", "jobId", "jobID")) + if not bohr_id: + keys = sorted(data) if isinstance(data, dict) else type(data).__name__ + raise BohrCLIError( + f"bohr job submit did not return a Bohr job ID (data: {keys})" + ) + return bohr_id + + def status(self, external_id: str) -> RemoteJobStatus: + data = run_bohr_json(["job", "describe", "-i", str(external_id)]) or {} + phase = str(data.get("phase", "")).lower() + normalized = _PHASE_TO_NORMALIZED.get(phase) + error = None + if phase == "failed": + error = str(data.get("errorInfo") or "").strip() or None + return RemoteJobStatus( + normalized_status=normalized, + snapshot={"phase": phase or None, "terminal": bool(data.get("terminal", False))}, + error=error, + ) + + def cancel(self, external_id: str) -> None: + run_bohr_json(["job", "terminate", "--id", str(external_id), "--no-wait"]) + + def collect_outputs(self, external_id: str, destination_dir: str | Path) -> list[dict[str, Any]]: + dest = Path(destination_dir).expanduser().resolve() + dest.mkdir(parents=True, exist_ok=True) + run_bohr_json(["job", "download", "-i", str(external_id), "--out", str(dest)]) + return [ + {"source": external_id, "destination": str(path)} + for path in sorted(dest.rglob("*")) + if path.is_file() + ] diff --git a/src/matcreator/control_plane/providers/bohr_sandbox.py b/src/matcreator/control_plane/providers/bohr_sandbox.py new file mode 100644 index 00000000..b3dc7bbe --- /dev/null +++ b/src/matcreator/control_plane/providers/bohr_sandbox.py @@ -0,0 +1,118 @@ +"""Interactive adapter over `bohr sandbox` (create/exec/files/describe/delete). + +Mirrors the E2B adapter's capability surface (interactive exec + file +transfer) so agent tools can treat a Bohrium CLI sandbox the same way as an +E2B one. The installed `bohr` CLI has no sandbox pause/resume subcommand +(only create/delete/describe/exec/files/list/...), so +``RemoteJobCapability.PAUSE`` is intentionally not declared here — add it if +a future CLI version exposes one. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ._bohr_cli import BohrCLIError, extract_id, run_bohr_json +from .base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus + +# Sandboxes reporting one of these as their describe-output status/state are +# no longer usable; treat them the same as an E2B "unreachable" liveness +# probe failure so the monitor marks the job lost rather than looping. +_UNREACHABLE_STATUSES = {"deleted", "terminated", "stopped", "killed"} + + +class BohrSandboxAdapter(RemoteJobAdapter): + provider = "bohr_sandbox" + capabilities = frozenset({RemoteJobCapability.INTERACTIVE_EXEC, RemoteJobCapability.FILE_TRANSFER}) + poll_interval_seconds = 15.0 + + def create(self, spec: dict[str, Any]) -> str: + project_id = spec.get("project_id") + if not project_id: + raise ValueError("bohr_sandbox spec requires 'project_id'") + template = spec.get("template") + if not template: + # Never fall back to the CLI's default template (sdbxagent): it + # silently creates the wrong (and possibly costlier) sandbox. + raise ValueError("bohr_sandbox spec requires 'template'") + args = ["sandbox", "create", "--template", str(template), "--project-id", str(project_id)] + if spec.get("timeout"): + args += ["--timeout", str(spec["timeout"])] + if spec.get("image"): + args += ["--image", str(spec["image"])] + if spec.get("gpu"): + args += ["--gpu", str(spec["gpu"])] + if spec.get("never_timeout"): + args.append("--never-timeout") + if spec.get("mount_user_storage"): + args.append("--mount-user-storage") + if spec.get("share_subpath"): + args += ["--share-subpath", str(spec["share_subpath"])] + if spec.get("session_id"): + args += ["--session-id", str(spec["session_id"])] + for key, value in dict(spec.get("env") or {}).items(): + args += ["--env", f"{key}={value}"] + + data = run_bohr_json(args) + # The CLI is inconsistent about the ID key across subcommands: + # `create`/`exec` return "sandboxID", `files read` returns + # "sandbox_id" — accept every observed spelling. + sandbox_id = extract_id(data, ("sandbox_id", "sandboxID", "sandboxId", "id")) + if not sandbox_id: + keys = sorted(data) if isinstance(data, dict) else type(data).__name__ + raise BohrCLIError( + f"bohr sandbox create did not return a sandbox ID (data: {keys})" + ) + return sandbox_id + + def status(self, external_id: str) -> RemoteJobStatus: + data = run_bohr_json(["sandbox", "describe", external_id]) or {} + raw_status = str(data.get("status") or data.get("state") or "").lower() + normalized = "lost" if raw_status in _UNREACHABLE_STATUSES else None + return RemoteJobStatus( + normalized_status=normalized, + snapshot={ + "provider_status": "unreachable" if normalized else "reachable", + "raw_status": raw_status or None, + }, + error=None, + ) + + def cancel(self, external_id: str) -> None: + run_bohr_json(["sandbox", "delete", external_id, "--force"]) + + def run_command(self, external_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + # `bohr sandbox exec` caps a command at 90s by default; pass + # `--timeout 0` to disable that CLI-side cap, and also don't bound our + # own subprocess wait, so a long-running command isn't silently + # truncated. This matches the E2B adapter's `timeout=0` semantics + # (see e2b.py run_command) so command duration behaves the same + # regardless of which interactive sandbox provider is in use. + data = run_bohr_json( + ["sandbox", "exec", external_id, "--command", command, "--user", user, "--timeout", "0"], + timeout=None, + ) or {} + return { + "stdout": str(data.get("stdout", "")), + "stderr": str(data.get("stderr", "")), + "exit_code": data.get("exit_code", data.get("exitCode")), + } + + def upload_file(self, external_id: str, source: str | Path, destination: str) -> None: + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + raise FileNotFoundError(source_path) + run_bohr_json(["sandbox", "files", "write", external_id, destination, "--source", str(source_path)]) + + def download_file( + self, + external_id: str, + source: str, + destination: str | Path, + *, + user: str | None = None, + ) -> Path: + dest_path = Path(destination).expanduser().resolve() + dest_path.parent.mkdir(parents=True, exist_ok=True) + run_bohr_json(["sandbox", "files", "read", external_id, source, "--destination", str(dest_path)]) + return dest_path diff --git a/src/matcreator/control_plane/e2b.py b/src/matcreator/control_plane/providers/e2b.py similarity index 63% rename from src/matcreator/control_plane/e2b.py rename to src/matcreator/control_plane/providers/e2b.py index fc0ca055..e3243893 100644 --- a/src/matcreator/control_plane/e2b.py +++ b/src/matcreator/control_plane/providers/e2b.py @@ -6,6 +6,8 @@ from typing import Any import os +from .base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus + # e2b SDK >=2.20 validates the API key format client-side (requires the # "e2b_" + hex pattern, see e2b.api.validate_api_key) and raises # AuthenticationException in ApiClient.__init__ before any network call. @@ -25,6 +27,27 @@ class E2BUnavailableError(RuntimeError): """Raised when the optional E2B SDK is unavailable at runtime.""" +@dataclass(frozen=True) +class E2BConnectionConfig: + """Server-side E2B/Bohrium endpoint configuration for one sandbox request.""" + + api_key: str + api_url: str + project_id: str + template: str + + def to_spec_dict(self, *, timeout: int = 600, lifecycle: dict[str, Any] | None = None) -> dict[str, Any]: + """Build the generic ``spec`` dict passed to ``E2BSandboxAdapter.create``.""" + return { + "template": self.template, + "api_key": self.api_key, + "api_url": self.api_url, + "project_id": self.project_id, + "timeout": timeout, + "lifecycle": lifecycle or {}, + } + + @dataclass(frozen=True) class E2BSandboxSpec: """Validated inputs for creating one E2B sandbox.""" @@ -54,10 +77,36 @@ def create_kwargs(self) -> dict[str, Any]: "metadata": self.metadata, } + @classmethod + def from_dict(cls, spec: dict[str, Any]) -> "E2BSandboxSpec": + """Build a validated spec from the generic dict a submit tool provides.""" + return cls( + template=str(spec.get("template", "")), + api_key=str(spec.get("api_key", "")), + api_url=str(spec.get("api_url", "")), + project_id=str(spec.get("project_id", "")), + timeout=int(spec.get("timeout", 600)), + lifecycle=dict(spec.get("lifecycle") or {}), + metadata=dict(spec.get("metadata") or {}), + ) + # The backend E2B SDK is imported lazily to avoid a hard dependency on the SDK for users who don't need it. The E2BSandboxAdapter class wraps the SDK and provides a simple interface for creating, connecting to, and managing E2B sandboxes. -class E2BSandboxAdapter: +class E2BSandboxAdapter(RemoteJobAdapter): """Small boundary around the E2B SDK with no SDK import at module load.""" + provider = "e2b" + # Resume is not implemented: the E2B SDK's "pause" produces a snapshot that + # is restored by simply connecting to the same sandbox_id again, so there + # is no separate resume operation to expose. + capabilities = frozenset( + { + RemoteJobCapability.PAUSE, + RemoteJobCapability.INTERACTIVE_EXEC, + RemoteJobCapability.FILE_TRANSFER, + } + ) + poll_interval_seconds = 15.0 + @staticmethod def _sandbox_class(): try: @@ -68,8 +117,20 @@ def _sandbox_class(): ) from exc return Sandbox - def create(self, spec: E2BSandboxSpec) -> str: - sandbox = self._sandbox_class().create(**spec.create_kwargs()) + def create(self, spec: dict[str, Any]) -> str: + sandbox_spec = E2BSandboxSpec.from_dict(spec) + try: + sandbox = self._sandbox_class().create(**sandbox_spec.create_kwargs()) + except (E2BConfigurationError, E2BUnavailableError): + raise + except Exception as exc: + # Include every non-secret request parameter so a provider-side + # error (404 wrong URL, unknown template, bad project) is + # diagnosable from the durable job record alone. + raise RuntimeError( + f"{exc} [create against api_url={sandbox_spec.api_url!r}, " + f"template={sandbox_spec.template!r}, project_id={sandbox_spec.project_id!r}]" + ) from exc sandbox_id = getattr(sandbox, "sandbox_id", "") if not sandbox_id: raise RuntimeError("E2B create returned a sandbox without sandbox_id") @@ -170,6 +231,9 @@ def pause(self, sandbox_id: str) -> None: def terminate(self, sandbox_id: str) -> None: self._connect(sandbox_id).kill() + def cancel(self, external_id: str) -> None: + self.terminate(external_id) + def probe(self, sandbox_id: str) -> dict[str, Any]: """Confirm an active sandbox is reachable without changing its files.""" result = self.run_command(sandbox_id, "true") @@ -177,7 +241,31 @@ def probe(self, sandbox_id: str) -> dict[str, Any]: raise RuntimeError(result["stderr"] or "E2B sandbox liveness probe failed") return {"provider_status": "reachable", "probe": result} + def status(self, external_id: str) -> RemoteJobStatus: + """Report liveness only: an E2B sandbox stays "running" until an agent + or user explicitly pauses/terminates it, so there is no independent + lifecycle status for the monitor to observe beyond reachability. + """ + snapshot = self.probe(external_id) + return RemoteJobStatus(normalized_status=None, snapshot=snapshot, error=None) + def _connect(self, sandbox_id: str): if not sandbox_id: raise ValueError("sandbox_id is required") - return self._sandbox_class().connect(sandbox_id) \ No newline at end of file + # Reconnects (run_command/pause/kill, possibly from a fresh process + # long after create) must target the same Bohrium endpoint as create. + # The SDK would silently fall back to the public e2b.dev API when + # called bare, so pass the configured connection explicitly and fail + # loudly when it is missing. + api_key = os.environ.get("E2B_API_KEY", "") + api_url = os.environ.get("E2B_API_URL", "") + if not api_key or not api_url: + raise E2BConfigurationError( + "E2B_API_KEY and E2B_API_URL must be set to connect to sandbox " + f"'{sandbox_id}'" + ) + opts: dict[str, Any] = {"api_key": api_key, "api_url": api_url} + project_id = os.environ.get("BOHRIUM_PROJECT_ID", "") + if project_id: + opts["headers"] = {"X-Project-Id": project_id} + return self._sandbox_class().connect(sandbox_id, **opts) \ No newline at end of file diff --git a/src/matcreator/control_plane/providers/registry.py b/src/matcreator/control_plane/providers/registry.py new file mode 100644 index 00000000..ed01ad3c --- /dev/null +++ b/src/matcreator/control_plane/providers/registry.py @@ -0,0 +1,50 @@ +"""Registry mapping remote-job provider names to adapter instances. + +Adding a new provider means adding one adapter module implementing +``RemoteJobAdapter`` and one ``register_adapter`` call in +``providers/__init__.py`` — nothing else in the control plane changes. +""" +from __future__ import annotations + +from typing import Callable + +from .base import RemoteJobAdapter + +_FACTORIES: dict[str, Callable[[], RemoteJobAdapter]] = {} +_INSTANCES: dict[str, RemoteJobAdapter] = {} + + +def register_adapter(provider: str, factory: Callable[[], RemoteJobAdapter]) -> None: + """Register a lazy factory for one provider's adapter. + + Factories are not called until first use, so importing the registry never + imports an optional provider SDK or shells out to a CLI. + """ + if not provider: + raise ValueError("provider is required") + _FACTORIES[provider] = factory + _INSTANCES.pop(provider, None) + + +def get_adapter(provider: str) -> RemoteJobAdapter: + """Return the (lazily constructed, cached) adapter for ``provider``.""" + if provider not in _FACTORIES: + raise KeyError(f"No remote-job adapter is registered for provider '{provider}'") + if provider not in _INSTANCES: + adapter = _FACTORIES[provider]() + if adapter.provider != provider: + raise ValueError( + f"Adapter registered for '{provider}' reports provider '{adapter.provider}'" + ) + _INSTANCES[provider] = adapter + return _INSTANCES[provider] + + +def registered_providers() -> list[str]: + return sorted(_FACTORIES) + + +def reset_registry() -> None: + """Test helper: drop all registrations and cached adapter instances.""" + _FACTORIES.clear() + _INSTANCES.clear() diff --git a/src/matcreator/control_plane/remote_job_monitor.py b/src/matcreator/control_plane/remote_job_monitor.py index dadb2967..e2cb5c45 100644 --- a/src/matcreator/control_plane/remote_job_monitor.py +++ b/src/matcreator/control_plane/remote_job_monitor.py @@ -10,10 +10,14 @@ class RemoteJobMonitor: - """Probe active E2B sandboxes with bounded retry backoff. + """Probe active remote jobs of any registered provider with bounded retry backoff. Job records are durable; this monitor's due times are intentionally process - local. On a restart its empty schedule reconciles every active sandbox once. + local. On a restart its empty schedule reconciles every active job once. + Each provider adapter declares its own ``poll_interval_seconds`` (see + ``providers/base.py``), so a batch/HPC-style provider can poll far less + often than an interactive sandbox with no change needed here — adding a + provider is a pure plugin. """ def __init__( @@ -45,30 +49,46 @@ async def run(self) -> None: def stop(self) -> None: self._stop.set() + def _base_interval(self, provider: str) -> float: + """Return the owning adapter's preferred poll cadence for one job. + + Resolved through ``self.service.adapter_for`` so this honors the same + adapter overrides (e.g. in tests) as every other service operation, + instead of querying the global registry directly. Falls back to this + monitor's own tick interval if the provider is unregistered (e.g. a + record left over from a removed plugin), so a missing adapter never + breaks reconciliation of other jobs. + """ + try: + return self.service.adapter_for(provider).poll_interval_seconds + except KeyError: + return self.interval_seconds + async def reconcile_once(self) -> list[dict[str, Any]]: now = time.monotonic() updates: list[dict[str, Any]] = [] active_ids: set[str] = set() - for job in self.store.list_active_jobs(provider="e2b"): + for job in self.store.list_active_jobs(): job_id = job["job_id"] active_ids.add(job_id) if job["status"] not in {"queued", "running", "submitting", "resuming"}: continue if now < self._next_due.get(job_id, 0): continue - updated = await asyncio.to_thread(self.service.reconcile_e2b, job_id) + base_interval = self._base_interval(job["provider"]) + updated = await asyncio.to_thread(self.service.reconcile_job, job_id) updates.append(updated) if updated["snapshot"].get("provider_status") == "unreachable": failures = self._failures.get(job_id, 0) + 1 self._failures[job_id] = failures - delay = min(self.interval_seconds * (2 ** (failures - 1)), self.max_backoff_seconds) + delay = min(base_interval * (2 ** (failures - 1)), self.max_backoff_seconds) else: self._failures.pop(job_id, None) - delay = self.interval_seconds + delay = base_interval self._next_due[job_id] = time.monotonic() + delay stale_ids = set(self._next_due) - active_ids for job_id in stale_ids: self._next_due.pop(job_id, None) self._failures.pop(job_id, None) - return updates \ No newline at end of file + return updates diff --git a/src/matcreator/control_plane/remote_job_service.py b/src/matcreator/control_plane/remote_job_service.py index 1e1cf060..b7162dc8 100644 --- a/src/matcreator/control_plane/remote_job_service.py +++ b/src/matcreator/control_plane/remote_job_service.py @@ -1,188 +1,389 @@ -"""Provider operations coordinated with durable remote-job records.""" +"""Provider operations coordinated with durable remote-job records. + +``RemoteJobService`` never branches on a provider name itself: every +operation looks up the adapter registered for ``job["provider"]`` (see +``providers/registry.py``) and checks its declared capabilities before +calling an optional method. Adding a new remote-job provider is therefore a +pure plugin: implement ``RemoteJobAdapter`` and register it — no changes +needed here. +""" from __future__ import annotations -from dataclasses import dataclass +import base64 +import time from pathlib import Path from typing import Any -from .e2b import E2BSandboxAdapter, E2BSandboxSpec +from .providers import CapabilityError, RemoteJobAdapter, RemoteJobCapability, get_adapter from .remote_jobs import RemoteJobStore -@dataclass(frozen=True) -class E2BConnectionConfig: - api_key: str - api_url: str - project_id: str - template: str - - class RemoteJobService: """Coordinates provider side effects with persisted job state.""" - def __init__(self, store: RemoteJobStore, *, e2b_adapter: E2BSandboxAdapter | None = None) -> None: + def __init__( + self, + store: RemoteJobStore, + *, + adapter_overrides: dict[str, RemoteJobAdapter] | None = None, + ) -> None: + """Create a service backed by ``store``. + + ``adapter_overrides`` lets callers (chiefly tests) inject a fake + adapter for one provider without mutating the global registry; + providers not present in the override map fall back to + ``providers.get_adapter``. + """ self.store = store - self.e2b_adapter = e2b_adapter or E2BSandboxAdapter() + self._adapter_overrides = dict(adapter_overrides or {}) + + def adapter_for(self, provider: str) -> RemoteJobAdapter: + """Resolve the adapter this service would use for ``provider``. + + Public so callers that need adapter metadata without performing an + operation (e.g. :class:`RemoteJobMonitor` reading + ``poll_interval_seconds``) resolve through the same override-aware + lookup as every service method, instead of querying the global + registry directly and silently ignoring test overrides. + """ + if provider in self._adapter_overrides: + return self._adapter_overrides[provider] + return get_adapter(provider) + + def _adapter(self, provider: str) -> RemoteJobAdapter: + return self.adapter_for(provider) - def submit_e2b( + def _get_job(self, job_id: str) -> dict[str, Any]: + job = self.store.get_job(job_id) + if job is None: + raise KeyError(f"Remote job '{job_id}' was not found") + if not job["external_id"]: + raise ValueError(f"Remote job '{job_id}' has no provider-side ID") + return job + + def submit_job( self, *, owner_id: str, session_id: str, + provider: str, idempotency_key: str, - connection: E2BConnectionConfig, + spec: dict[str, Any], node_id: str | None = None, step_number: int | None = None, - timeout: int = 600, - lifecycle: dict[str, Any] | None = None, - metadata: dict[str, str] | None = None, output_dir: str | None = None, + persisted_specification: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Create an E2B sandbox once and persist the resulting sandbox ID. + """Create one external job/sandbox once and persist its external ID. - The stored specification intentionally excludes ``api_key``. Replays with - the same idempotency key return the already-created record instead of - creating another sandbox. + ``spec`` is passed to the provider adapter's ``create`` verbatim and + may contain secrets (e.g. an API key). ``persisted_specification`` — + which must never contain secrets — is stored in the durable record + instead; if omitted, ``spec`` itself is persisted, so callers whose + spec has no secrets can rely on the default. Replays with the same + idempotency key return the already-created record instead of + creating a second external job; a record that failed before ever + acquiring an external ID is reset and retried instead. """ job = self.store.create_job( owner_id=owner_id, session_id=session_id, - provider="e2b", + provider=provider, idempotency_key=idempotency_key, node_id=node_id, step_number=step_number, - specification={ - "template": connection.template, - "api_url": connection.api_url, - "project_id": connection.project_id, - "timeout": timeout, - "lifecycle": lifecycle or {}, - "metadata": metadata or {}, - }, + specification=persisted_specification if persisted_specification is not None else spec, output_dir=output_dir, ) + if job["status"] == "failed" and not job["external_id"]: + # The previous attempt died before the provider handed back an + # external ID, so nothing external exists to duplicate — retry + # instead of returning the poisoned record forever. + job = self.store.reset_failed_job_for_retry(job["job_id"]) if job["external_id"] or job["status"] != "created": return job + adapter = self._adapter(provider) submitting = self.store.transition_job(job["job_id"], "submitting") try: - sandbox_id = self.e2b_adapter.create( - E2BSandboxSpec( - template=connection.template, - api_key=connection.api_key, - api_url=connection.api_url, - project_id=connection.project_id, - timeout=timeout, - lifecycle=lifecycle or {}, - metadata=metadata or {}, - ) - ) + external_id = adapter.create(spec) except Exception as exc: return self.store.transition_job( job["job_id"], "failed", - error=f"E2B sandbox creation failed: {exc}", + error=f"{provider} job creation failed: {exc}", expected_revision=submitting["state_revision"], ) + + # Give the adapter a chance to report an initial lifecycle status + # (e.g. a batch provider that queues before it runs) instead of + # always assuming "running". + initial_status = "running" + initial_snapshot: dict[str, Any] = {"provider_status": "running"} + try: + probe = adapter.status(external_id) + except Exception: + probe = None + if probe is not None: + if probe.normalized_status: + initial_status = probe.normalized_status + initial_snapshot = {**initial_snapshot, **probe.snapshot} + return self.store.transition_job( job["job_id"], - "running", - external_id=sandbox_id, - snapshot={"provider_status": "running", "sandbox_id": sandbox_id}, + initial_status, + external_id=external_id, + snapshot=initial_snapshot, expected_revision=submitting["state_revision"], ) - def pause_e2b(self, job_id: str) -> dict[str, Any]: - self._get_e2b_job(job_id) + def pause_job(self, job_id: str) -> dict[str, Any]: + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.PAUSE not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.PAUSE) requested = self.store.transition_job(job_id, "pause_requested") try: - self.e2b_adapter.pause(requested["external_id"]) + adapter.pause(requested["external_id"]) except Exception as exc: return self.store.transition_job( job_id, "failed", - error=f"E2B pause failed: {exc}", + error=f"{job['provider']} pause failed: {exc}", expected_revision=requested["state_revision"], ) - return self.store.transition_job( - job_id, "paused", expected_revision=requested["state_revision"] - ) + return self.store.transition_job(job_id, "paused", expected_revision=requested["state_revision"]) - def terminate_e2b(self, job_id: str) -> dict[str, Any]: - self._get_e2b_job(job_id) + def resume_job(self, job_id: str) -> dict[str, Any]: + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.RESUME not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.RESUME) + requested = self.store.transition_job(job_id, "resume_requested") + resuming = self.store.transition_job(job_id, "resuming", expected_revision=requested["state_revision"]) + try: + adapter.resume(resuming["external_id"]) + except Exception as exc: + return self.store.transition_job( + job_id, + "failed", + error=f"{job['provider']} resume failed: {exc}", + expected_revision=resuming["state_revision"], + ) + return self.store.transition_job(job_id, "running", expected_revision=resuming["state_revision"]) + + def terminate_job(self, job_id: str) -> dict[str, Any]: + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) requested = self.store.transition_job(job_id, "terminate_requested") try: - self.e2b_adapter.terminate(requested["external_id"]) + adapter.cancel(requested["external_id"]) except Exception as exc: return self.store.transition_job( job_id, "lost", - error=f"E2B termination could not be confirmed: {exc}", + error=f"{job['provider']} termination could not be confirmed: {exc}", expected_revision=requested["state_revision"], ) - return self.store.transition_job( - job_id, "terminated", expected_revision=requested["state_revision"] - ) + return self.store.transition_job(job_id, "terminated", expected_revision=requested["state_revision"]) - def pause_active_session_e2b_jobs(self, *, owner_id: str, session_id: str) -> list[dict[str, Any]]: - """Request a provider pause for each active E2B job in one session.""" + def pause_active_session_jobs(self, *, owner_id: str, session_id: str) -> list[dict[str, Any]]: + """Request a provider pause for each active, pausable job in one session.""" results: list[dict[str, Any]] = [] for job in self.store.list_jobs(owner_id=owner_id, session_id=session_id): - if job["provider"] != "e2b" or job["status"] not in {"queued", "running"}: + if job["status"] not in {"queued", "running"}: continue try: - results.append(self.pause_e2b(job["job_id"])) + adapter = self._adapter(job["provider"]) + except KeyError: + continue + if RemoteJobCapability.PAUSE not in adapter.capabilities: + continue + try: + results.append(self.pause_job(job["job_id"])) except Exception as exc: - results.append( - { - "job_id": job["job_id"], - "status": job["status"], - "pause_error": str(exc), - } - ) + results.append({"job_id": job["job_id"], "status": job["status"], "pause_error": str(exc)}) return results - def reconcile_e2b(self, job_id: str) -> dict[str, Any]: - """Probe a persisted active sandbox after a process restart or refresh.""" - job = self._get_e2b_job(job_id) + def reconcile_job(self, job_id: str) -> dict[str, Any]: + """Probe a persisted active job after a process restart or refresh. + + Transitions the durable status only when the adapter reports a + normalized status that differs from the current one; otherwise the + probe result is merged as a non-lifecycle observation. An illegal + transition reported by a confused/stale adapter observation falls + back to an observation rather than raising, since a monitor loop + must never crash on one bad probe. + """ + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "submitting", "resuming"}: return job + adapter = self._adapter(job["provider"]) try: - snapshot = self.e2b_adapter.probe(job["external_id"]) + probe = adapter.status(job["external_id"]) except Exception as exc: return self.store.record_observation( job_id, snapshot={"provider_status": "unreachable"}, - error=f"E2B reconciliation failed: {exc}", + error=f"{job['provider']} reconciliation failed: {exc}", expected_revision=job["state_revision"], ) + if probe.normalized_status and probe.normalized_status != job["status"]: + try: + return self.store.transition_job( + job_id, + probe.normalized_status, + snapshot=probe.snapshot, + error=probe.error, + expected_revision=job["state_revision"], + ) + except ValueError: + # Provider reported a status this job's current state cannot + # legally move to (e.g. a stale/out-of-order observation). + # Recording it as telemetry is always safe; only a lifecycle + # transition needs the strict check. + pass return self.store.record_observation( job_id, - snapshot=snapshot, - error=None, + snapshot=probe.snapshot, + error=probe.error, expected_revision=job["state_revision"], ) - def run_e2b_command(self, job_id: str, command: str, *, user: str = "root") -> dict[str, Any]: - """Run one command inside a tracked E2B sandbox without persisting command text.""" - job = self._get_e2b_job(job_id) + def run_job_command(self, job_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + """Run one command inside a tracked interactive job without persisting command text. + + This blocks the caller for the command's full duration with no + timeout of its own. Fine for short commands (seconds); for anything + that might run more than a minute or two, use + ``start_job_command``/``poll_job_command`` instead, which never + blocks longer than one bounded status check and durably survives a + process restart mid-command. + """ + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "resuming"}: - raise ValueError(f"E2B job '{job_id}' cannot run commands while {job['status']}") - result = self.e2b_adapter.run_command(job["external_id"], command, user=user) + raise ValueError(f"Job '{job_id}' cannot run commands while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.INTERACTIVE_EXEC not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.INTERACTIVE_EXEC) + result = adapter.run_command(job["external_id"], command, user=user) self.store.merge_observation( job_id, - snapshot={"provider_status": "reachable", "last_command_exit_code": result["exit_code"]}, + snapshot={"provider_status": "reachable", "last_command_exit_code": result.get("exit_code")}, error=None, ) return result - def upload_e2b_file(self, job_id: str, source: str | Path, destination: str) -> dict[str, Any]: - """Upload one local input file into a tracked E2B sandbox.""" - job = self._get_e2b_job(job_id) + def start_job_command(self, job_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + """Launch one command in the background inside a tracked interactive job. + + Built entirely on the existing ``run_command`` capability — no new + adapter method or capability is required, so this works for any + current or future ``INTERACTIVE_EXEC`` provider automatically. The + launch call itself returns almost immediately (only the wrapper + shell backgrounds and detaches; it does not wait for ``command`` to + finish). ``command`` is base64-encoded before being embedded in the + wrapper so arbitrary shell content (quotes, `$`, backticks, newlines) + can never break out of or reinterpret the wrapper script. + + The command's stdout/stderr and exit code are redirected to marker + files whose paths are derived only from ``job_id`` and persisted in + the job's durable snapshot. This is what makes the command + recoverable: if this process crashes or the connection drops while + the command is still running, a fresh process re-attaches to the + same job, reads the same marker-file paths from the durable record, + and calls ``poll_job_command`` — it never has to guess whether an + earlier command already ran or re-issue it, which would be unsafe + for a non-idempotent computation. + """ + job = self._get_job(job_id) + if job["status"] not in {"queued", "running", "resuming"}: + raise ValueError(f"Job '{job_id}' cannot run commands while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.INTERACTIVE_EXEC not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.INTERACTIVE_EXEC) + + marker = f"/tmp/matcreator-cmd-{job_id}" + log_path = f"{marker}.log" + exit_path = f"{marker}.exit" + payload = base64.b64encode(command.encode("utf-8")).decode("ascii") + launch = ( + f"rm -f {exit_path}; " + f"nohup sh -c 'echo {payload} | base64 -d | sh; echo $? > {exit_path}' " + f"> {log_path} 2>&1 < /dev/null & echo LAUNCHED" + ) + launch_result = adapter.run_command(job["external_id"], launch, user=user) + handle = {"log_path": log_path, "exit_path": exit_path, "started_at": time.time()} + self.store.merge_observation( + job_id, + snapshot={"provider_status": "reachable", "background_command": handle}, + error=None, + ) + return {"job_id": job_id, "launch": launch_result, "handle": handle} + + def poll_job_command(self, job_id: str, *, tail_bytes: int = 8000) -> dict[str, Any]: + """Check on the job's most recently started background command. + + Reads only the durable marker-file paths from the job's snapshot, so + this works identically whether it's the same process that started + the command or a freshly re-attached one after a restart. Returns + ``{"running": True, ...}`` while the exit marker hasn't appeared yet, + or ``{"running": False, "exit_code": ..., "output_tail": ...}`` once + it has — ``output_tail`` is the last ``tail_bytes`` of combined + stdout/stderr; use ``download_job_file`` on ``log_path`` for the + full output of a long-running command. + """ + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.INTERACTIVE_EXEC not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.INTERACTIVE_EXEC) + handle = (job.get("snapshot") or {}).get("background_command") + if not isinstance(handle, dict) or not handle.get("exit_path"): + raise ValueError(f"Job '{job_id}' has no in-flight background command") + + exit_path = handle["exit_path"] + log_path = handle["log_path"] + check = adapter.run_command( + job["external_id"], + f"if [ -f {exit_path} ]; then echo DONE:$(cat {exit_path}); else echo RUNNING; fi", + user="root", + ) + stdout = str(check.get("stdout", "")).strip() + if not stdout.startswith("DONE:"): + self.store.merge_observation( + job_id, snapshot={"provider_status": "reachable"}, error=None + ) + return {"running": True, "log_path": log_path} + + try: + exit_code = int(stdout.split(":", 1)[1].strip()) + except (IndexError, ValueError): + exit_code = None + tail = adapter.run_command( + job["external_id"], f"tail -c {int(tail_bytes)} {log_path} 2>/dev/null || true", user="root" + ) + self.store.merge_observation( + job_id, + snapshot={"provider_status": "reachable", "background_command": None, "last_command_exit_code": exit_code}, + error=None, + ) + return { + "running": False, + "exit_code": exit_code, + "output_tail": tail.get("stdout", ""), + "log_path": log_path, + } + + def upload_job_file(self, job_id: str, source: str | Path, destination: str) -> dict[str, Any]: + """Upload one local input file into a tracked interactive job.""" + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "resuming"}: - raise ValueError(f"E2B job '{job_id}' cannot receive files while {job['status']}") + raise ValueError(f"Job '{job_id}' cannot receive files while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.FILE_TRANSFER not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.FILE_TRANSFER) source_path = Path(source).expanduser().resolve() - self.e2b_adapter.upload_file(job["external_id"], source_path, destination) + adapter.upload_file(job["external_id"], source_path, destination) self.store.merge_observation( job_id, snapshot={"provider_status": "reachable", "last_upload": source_path.name}, @@ -190,17 +391,16 @@ def upload_e2b_file(self, job_id: str, source: str | Path, destination: str) -> ) return {"source": str(source_path), "destination": destination} - def download_e2b_file(self, job_id: str, source: str, destination: str | Path) -> dict[str, Any]: - """Download one sandbox file to a local destination path. - - Streams the file via the E2B filesystem API so large outputs (CHGCAR, - vasprun.xml, PNG) are not truncated by command-output limits. - """ - job = self._get_e2b_job(job_id) + def download_job_file(self, job_id: str, source: str, destination: str | Path) -> dict[str, Any]: + """Download one file from a tracked interactive job to a local path.""" + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "resuming"}: - raise ValueError(f"E2B job '{job_id}' cannot serve files while {job['status']}") + raise ValueError(f"Job '{job_id}' cannot serve files while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.FILE_TRANSFER not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.FILE_TRANSFER) dest_path = Path(destination).expanduser().resolve() - self.e2b_adapter.download_file(job["external_id"], source, dest_path) + adapter.download_file(job["external_id"], source, dest_path) self.store.merge_observation( job_id, snapshot={"provider_status": "reachable", "last_download": Path(source).name}, @@ -208,12 +408,35 @@ def download_e2b_file(self, job_id: str, source: str, destination: str | Path) - ) return {"source": source, "destination": str(dest_path)} - def _get_e2b_job(self, job_id: str) -> dict[str, Any]: - job = self.store.get_job(job_id) - if job is None: - raise KeyError(f"Remote job '{job_id}' was not found") - if job["provider"] != "e2b": - raise ValueError(f"Remote job '{job_id}' is not managed by E2B") - if not job["external_id"]: - raise ValueError(f"Remote job '{job_id}' has no sandbox ID") - return job \ No newline at end of file + def collect_job_outputs(self, job_id: str, destination_dir: str | Path) -> dict[str, Any]: + """Pull a finished batch job's output files into ``destination_dir``. + + Only valid once the job has reached ``succeeded``; transitions the + job through ``collecting`` -> ``collected`` so a repeated collection + request is a durable no-op rather than a duplicate download. + """ + job = self._get_job(job_id) + if job["status"] == "collected": + return job + if job["status"] != "succeeded": + raise ValueError(f"Job '{job_id}' cannot collect outputs while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.BATCH_COLLECT not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.BATCH_COLLECT) + collecting = self.store.transition_job(job_id, "collecting") + dest_path = Path(destination_dir).expanduser().resolve() + try: + artifacts = adapter.collect_outputs(job["external_id"], dest_path) + except Exception as exc: + return self.store.transition_job( + job_id, + "failed", + error=f"{job['provider']} output collection failed: {exc}", + expected_revision=collecting["state_revision"], + ) + return self.store.transition_job( + job_id, + "collected", + artifacts=artifacts, + expected_revision=collecting["state_revision"], + ) diff --git a/src/matcreator/control_plane/remote_jobs.py b/src/matcreator/control_plane/remote_jobs.py index e4a2c621..b736ea50 100644 --- a/src/matcreator/control_plane/remote_jobs.py +++ b/src/matcreator/control_plane/remote_jobs.py @@ -265,6 +265,47 @@ def transition_job( ) return self.get_job(job_id) or {} + def reset_failed_job_for_retry(self, job_id: str) -> dict[str, Any]: + """Return a failed job that never acquired an external ID to ``created``. + + ``failed`` is terminal for the normal transition machinery, but a job + that failed before the provider handed back an external ID has no + provider-side effect to duplicate, so re-running its submission is + safe. This is the one sanctioned exception, recorded as its own + ``retry`` event. Raises ``ValueError`` for any other job state. + """ + now = time.time() + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute("SELECT * FROM remote_jobs WHERE job_id = ?", (job_id,)).fetchone() + if row is None: + raise KeyError(f"Remote job '{job_id}' was not found") + current = self._decode(row) or {} + if current["status"] != "failed" or current["external_id"]: + raise ValueError( + f"Remote job '{job_id}' cannot be reset for retry " + f"(status={current['status']!r}, external_id={current['external_id']!r})" + ) + updated = connection.execute( + """ + UPDATE remote_jobs + SET status = 'created', error = NULL, + state_revision = state_revision + 1, updated_at = ? + WHERE job_id = ? AND state_revision = ? + """, + (now, job_id, current["state_revision"]), + ) + if updated.rowcount != 1: + raise RuntimeError("Remote job revision changed") + self._append_event( + connection, + job_id, + "retry", + {"from": "failed", "to": "created", "previous_error": current["error"]}, + now, + ) + return self.get_job(job_id) or {} + def record_observation( self, job_id: str, diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md b/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md index c4a33d1d..3694e526 100644 --- a/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md +++ b/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md @@ -51,179 +51,20 @@ Load the appropriate tool skill when needing detailed instructions (e.g., `load_ --- -# MLFF fine-tuning instructions +# MLFF workflows -When a user asks to generate a MLFF, they often imply fine-tuning a pretrained model, rather than training from scratch, -because the latter is way more computationally expensive. +The detailed, stage-by-stage procedures for generating a force field live in two dedicated reference files. +**Load the relevant reference before executing either workflow:** -When performing fine-tuning, the following procedure is preferred. +| Workflow | When to use | Reference | +|----------|-------------|-----------| +| **Fine-tuning** | Fine-tune a pre-trained model on DFT-labeled data of a target system (the common case). | [references/fine-tuning.md](references/fine-tuning.md) | +| **Distillation** | Train a smaller DPA-4c student model from scratch on teacher-labeled data, for large-scale / long-timescale production MD. | [references/distillation.md](references/distillation.md) | -## Recommended Procedure — Generate a force field via fine-tuning pretrained-model - - -### Phase Zero — Ask the user: Do you have a DFT-labelled dataset? - -A "DFT-labeled dataset" means structures whose energy, forces, and virial -were computed by DFT (VASP, ABACUS, etc.), **not** by a pretrained machine-learning model. - -> **Key principle:** The pretrained model is only a **surrogate for structural-space exploration** -> via molecular dynamics (MD), **not a ground truth**. The target that fine-tuning aims to match must be DFT data. -> All ground-truth labels used for fine-tuning and evaluation must come from **DFT calculations**. - -- **Bench mode** (`agent_mode == "bench"`): skip this question — assume NO dataset and - proceed directly to the "NO dataset" branch below. - -- **If the user HAS a DFT-labeled dataset:** -Proceed directly to Phase B below. - -- **If the user has NO DFT-labeled dataset:** - -Follow Phases A–C below. - - -### Phase A — Generate candidate structures for labeling via structure exploration - -1. **Classify the problem complexity:** - - **Simple systems** — bulk crystals, random alloys, simple compounds. - - **Complex systems** — defects, dopants, surfaces, interfaces, transition states, - high-entropy alloys, amorphous structures, etc. - -2. **For simple systems:** proceed directly to step 4 below. - -3. **For complex systems: ask the user if they already have initial structure files.** - If yes, use the user's structures as the starting point. If no, generate an intial structure - (or multiple initial structures, if needed) using the `atomic-structure` skill - (or `matcraft-kit` for surfaces/defects). - -4. **Choose simulation cell size for MD**: According to the following rules, determine whether the - initial structures need to be replicated into supercells. Do supercell operations only if needed, - and perform it only **ONCE** in the entire workflow. - - > **Rules for judging MD simulation cell size:** - > Keep each structure at roughly **50 atoms** when possible. - > For systems exceeding this size, - > do NOT perform supercell operations — use the original cell as-is. - -5. **Generate candidate structures** for MD exploration: - - Refer to the `ase` skill for details of using ASE. - - Use the resulting structure (or structures) from step 4 as the starting simulation cell (ase.Atoms). - - Use the pretrained model to set the simulation cell's calculator. - - Relax the structure (optimize both atomic coordinates and lattice vectors) first to avoid MD collapse. - - Explore configuration space via **NPT-ensamble MD**. - - **MD sampling skill choice:** `ase` >> `lammps`. Try `ase` first; - if it fails repeatedly, switch to `lammps`. Never use `atomic-structure` for MD. - - - **MD sampling parameters (NPT ensemble):** - - Adjust the following parameters according to the table below and the specific needs of the system. - - | Parameter | Default value | Description | - |---------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| - | Ensemble | **NPT** | NPT ensemble is mandatory for structure exploration | - | Temperature | **300 K, 600 K, 900 K** | Target temperatures. Use 300K, 600K, 900K as default. Adjust to user needs. For solid-state materials, **approach but never exceed the melting point**! | | - | Pressure | **1 bar, 10 Gpa** | Target pressure. For regular conditions, try from 1 bar and 10 GPa; adjust to user needs. | - | Step size | **2 fs** | Highest safe step size, decrease to 1 fs above 2000 K or when unstable (volume explosion) | - | Structure saving interval | Every **5** steps | Recommend to have at least **10 fs** spacing between two saved frames to have enough variation between structures. | - | Duration | **10 ps** | Total simulation time per temperature and per pressure | - | Output frames | **100** | Number of MD frames to retain from all temperatures and pressure samples. 100 is default. For more complex systems, use up to 500. | - - > Output frames recommendation: - > - **100** for simple systems (bulk crystals, random alloys, simple compounds) - > - **200** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) - > - **500** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) - -6. **Entropy-based structure selection (MANDATORY)** - After MD sampling, use entropy-based filtering to select a subset of 50% of the structures **with diversity** - from the obtained MD frames before DFT labeling to reduce DFT cost. For example: - ``` - run_skill_script( - skill_name="quests", - script_name="active_learning.py", - args="filter-by-entropy md_trajectory.extxyz --max-sel 50 --chunk-size 10" - ) - ``` - `chunk-size` had better be 1/50 of the total number of MD frames, but never below 10. - - > **CRITICAL:** Always run entropy-based selection BEFORE DFT labeling. Never send - > all sampled frames directly to DFT — use the selected structures instead. - - -### Phase B — DFT labeling - -Run DFT single-point calculations on the **selected structures** to obtain energy, -force, and virial labels. - -- Use the `vasp` or `abacus` skill for DFT input preparation and execution (`vasp` preferred). -- See `concepts/dft-calculation` for guidance on choosing a DFT code. -- Job submission is handled by the `bohrium` skill. - - -### Phase C — Fine-tuning & Evaluation - -> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**. - -1. Create the fresh workdir, and prepare input files in the fine-tuning workdir. For example, - for DPA models, you may run the script [deepmd/scripts/deepmd_prepare.py](deepmd/scripts/deepmd_prepare.py) - under the `deepmd` skill. - In this preparation stage, train/test split is performed. - Recommended train vs test split ratio is **4:1** for all DFT-labeled frames. - -2. Submit finetune job on Bohrium via the `bohrium` skill . - - 3. **Evaluate:** - Perform testing to obtain predicted energy (and per-atom energy), forces, virials (and per-atom virials) or - stress, then compute MAE errors. Also, perform such evaluation with the original pretrained model for comparison - with the fine-tuned model. - > For **DPA models**, the evaluation of both the pretrained and fine-tuned models are already taken care of - > by the commands generated - > with script [deepmd/scripts/deepmd_prepare.py](deepmd/scripts/deepmd_prepare.py), therefore the evaluation - > results will come back together with the fine-tuned model. - - > For **other MLFF models**, you may need to manually run the evaluation through the MLFF's native ase calculator interface. - > Refer to `ase` skill for guidance. - -4. When the system of your study used very different first-principle computation settings from the training set - of your pretrained model, energy MAE may not be comparable between the pretrained and fine-tuned models as - the zero point of energy may be different. In this case, you may need to adjust the energy bias of the pretrained - model for rational comparison. You may perform a quick adjustment like the following: - ```python - e_shift = np.mean(all_e_peratom_dft - all_e_peratom_predicted) - ``` - Then do: - ```python - get_mae( - all_e_peratom_dft, - all_e_peratom_predicted + e_shift - ), - ``` - to get comparable energy MAE. - -5. **Report and compare the results:** - - Pretrained: energy per atom MAE = X, force MAE = Y - - Finetuned: energy per atom MAE = X', force MAE = Y' - - Improvement: energy per atom MAE reduced by Z%, force MAE reduced by W% - ---- - -# MLFF distillation instructions - -Distillation is the process of training a smaller MLFF model **from scratch** using labels from a larger MLFF model. -The smaller MLFF model is called the **student model**, and the larger MLFF model is called the **teacher model**. - -MLFF must be appropriately distilled before applying the MLFF model to a large-scale simulation (> 100 K atoms, > 1 ns). - -The distillation workflow is largely the same as the fine-tuning workflow, except that: -- The labels can be generated by the teacher model instead of DFT. -- The student model is trained from scratch, as the result, - the training set should be much larger than the fine-tuning case - (usually 10 times the fine-tuning case is recommended). -- As notified before in fine-tuning, the resulting student model also has to be evaluated. - The evaluation must first be performed comparing the student model's prediction on the test set - against the teacher model's label, then comparing the student model's prediction against the - **ground truth** (DFT). Since distillation is often preceded by fine-tuning, you can reuse the - DFT-labeled test set from the corresponding fine-tuning workflow. If that is not available, then - consider choosing an appropriate subset of the distillation testing set and re-compute them with DFT - (subset number of frames times number of atoms in each frame should not exceed 10000 total atoms). +Both procedures follow the same three-stage shape — **Stage A** (candidate structure generation via NPT MD), +**Stage B** (labeling: DFT for fine-tuning, teacher inference for distillation) and **Stage C** (training + +evaluation). Distillation additionally requires a valid **teacher model** (a fine-tuned, single-task model on +the target system — **never** a pretrained model) and a much larger dataset (~100× the fine-tuning scale). --- diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md b/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md new file mode 100644 index 00000000..9d87ed80 --- /dev/null +++ b/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md @@ -0,0 +1,137 @@ +# MLFF distillation instructions + +Distillation is the process of training a smaller MLFF model **from scratch** using labels from a larger MLFF model. +The smaller MLFF model is called the **student model**, and the larger MLFF model is called the **teacher model**. + +MLFF must be appropriately distilled before applying the MLFF model to a large-scale simulation (> 100 K atoms, > 1 ns). + +The distillation workflow mirrors the fine-tuning workflow stage by stage, except that the labels are +generated by the teacher model instead of DFT, and the student model is trained from scratch on a much +larger dataset. The concept-level procedure below is the direct counterpart of the fine-tuning procedure. + +## Recommended Procedure — Generate a force field via distillation + +> **Strict stage ordering — Stage A → Stage B → Stage C.** +> These three stages must be executed **in order, with no stage skipped, no stage +> reordered, and no stage omitted because it "seems unnecessary" for the structure at +> hand.** The only allowed variation is tuning *parameters within each stage* +> (temperature, pressure, frame count, epochs, etc.); the stage *sequence and +> presence* are fixed. +> +> **Skipping the generation and labeling of new samples by running MD with the teacher +> model is NOT allowed.** In particular, do NOT simply relabel existing DFT structures +> with the teacher model and then use those structures to train the student model — this +> is not distillation and will not generalize. You MUST run a fresh teacher NPT-ensemble +> MD trajectory (Stage A), label the newly sampled frames with the teacher (Stage B), and +> only then train the student (Stage C). Jumping straight to Stage C (student training) on +> statically relabeled structures produces a student that reproduces the teacher on those +> frames but degrades by an order of magnitude on DFT-relaxed structures — the classic +> distillation failure mode. + +### Stage Zero — Gate: Is a valid teacher model available? + +A valid **teacher model** must satisfy BOTH conditions: + +1. It is a **fine-tuned** model on the target system, i.e., it has already gone through the + fine-tuning procedure on DFT-labeled data of that system. **DO NOT use pretrained models** — + a pretrained model must **NEVER** be used as a teacher. +2. It is a **single-task** model (dedicated to the target system). For a model that supports + multi-task training (such as DPA-3), the teacher must be a single-task model specifically + fine-tuned on the target system — a multi-task model is **not** a valid teacher. + +- **If no valid teacher exists** (the user only has a pretrained model, or only a multi-task model): + do NOT proceed with distillation. Return to the fine-tuning workflow first, produce a + fine-tuned single-task model for the target system, and only then come back to distillation. +- **Student model:** the student is restricted to **DPA-4c**. + +> All DPA-4c-specific CLI commands, scripts and environment setup are owned by the `deepmd` skill. +> Load the `deepmd` skill when executing any stage below; this concept file deliberately does not +> repeat concrete commands. + +### Stage A — Teacher MD exploration (candidate structure generation) + +Generate candidate structures using the **fine-tuned teacher model** as the MD calculator, +**reusing the fine-tuning Stage A sampling rules**: + +- Same structure preparation and cell-size rules (~50 atoms per structure; supercell replication at + most once and never for large systems). +- Explore configuration space via **NPT-ensemble MD** with the same parameter conventions as + fine-tuning (NPT ensemble is mandatory; same temperature/pressure/step-size/saving-interval rules). +- **Entropy-based structure selection is MANDATORY** after MD sampling and before labeling, + exactly as in fine-tuning Stage A. + +The only systematic difference is the sampling scale: because the student is trained from scratch, +distillation needs roughly **100 times** the number of frames used in fine-tuning. + +> Output frames recommendation (distillation): +> - **20000** for simple systems (bulk crystals, random alloys, simple compounds) +> - **40000** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) +> - **100000** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) + +> **Seed ≠ training set.** The POSCAR / cif structure you start with is a **seed** for +> MD exploration, not a training frame. You MUST first run teacher **NPT-ensemble MD** +> on the seed (Stage A) to generate diverse configurations, then label those +> configurations with the teacher (Stage B), and only then train the student on the +> labeled MD-sampled frames (Stage C). **Directly labeling the static seed structure +> (or a handful of manually-built structures) and training on it is a typical +> distillation failure and is strictly forbidden.** +> +> Recall the three non-negotiable Stage A rules that the seed-based workflow depends on: +> **(1) NPT ensemble** — mandatory for strain diversity; never switch to NVT/NVE without +> explicit user approval. **(2) Entropy-based structure selection** — mandatory after +> MD sampling and before labeling. **(3) ~100× frame scale** — distillation needs +> roughly 100× the frames of fine-tuning (≥ 20000 for simple systems). + +### Stage B — Teacher inference labeling (replaces DFT) + +By inferencing with the teacher model, obtain the energy, forces and virial labels for the +entropy-selected structures, instead of running DFT single-point calculations: + +- The teacher's predictions fully replace DFT single-point calculations in this stage; no DFT jobs + are launched. +- The teacher is guaranteed to be a fine-tuned **single-task** model by the Stage Zero gate, so there + is **no model head selection** involved — run inference with the teacher model as-is. +- MD/inference skill choice follows the same rules as fine-tuning (`ase` first, `lammps` as fallback). + +### Stage C — Student training from scratch & Evaluation + +> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**, as in fine-tuning. + +> **GATE — verify training-set provenance and size before proceeding to student +> training.** Before step 1 below, confirm ALL of the following: +> 1. The training frames were **produced by Stage A → Stage B** (teacher NPT MD +> sampling + entropy selection + teacher inference labeling), **not** statically +> labeled from the seed structure or relabeled from existing DFT structures. +> 2. The total labeled frame count is **≥ ~20000** (simple systems) / **≥ ~40000** +> (complex systems) / **≥ ~100000** (very complex systems), consistent with the +> Stage A output recommendation above. +> 3. The frames span a **diverse configuration space** — different strains, cell volumes +> and atomic positions sampled across the NPT trajectory (the NPT ensemble lets the +> lattice vectors fluctuate, producing a range of instantaneous cell shapes from the +> same starting supercell), not a single relaxed geometry. +> +> **If any check fails, STOP and return to Stage A** — regenerate/expand the MD +> trajectory and re-label. Do not proceed to student training on insufficient or +> non-MD-sourced data. + +1. Prepare the teacher-labeled data in a fresh workdir. The data preparation and **domain split must + follow exactly the same rules as fine-tuning Stage C**: train vs test split ratio is **4:1** for + all teacher-labeled frames. + +2. Train the DPA-4c student model **from scratch** (no pretrained initialization). Because the student + starts from scratch, training is much longer than fine-tuning: use about **50 epochs** (set via + the `num_epochs` keyword — do NOT instruct training in steps). For the concrete DPA-4c training + commands and environment, load and follow the `deepmd` skill. + +3. **Evaluate on two levels (both are required):** + - **Level 1 — student vs teacher:** compare the student's predictions against the teacher's labels + on the held-out test set (the 1/5 test split from step 1). This checks that the student faithfully + reproduces the teacher. + - **Level 2 — student vs DFT ground truth:** compare the student's predictions against DFT. + **Prefer reusing the DFT-labeled test set from the corresponding fine-tuning workflow.** If that + is not available, choose an appropriate subset of the distillation testing set and re-compute it + with DFT (subset number of frames times number of atoms in each frame should not exceed 10000 + total atoms). + +4. Distillation is a **single-round** procedure: train the student once and evaluate. Do NOT iterate + (no repeated teacher-relabeling rounds) within this workflow. diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md b/src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md new file mode 100644 index 00000000..3c43a468 --- /dev/null +++ b/src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md @@ -0,0 +1,153 @@ +# MLFF fine-tuning instructions + +When a user asks to generate a MLFF, they often imply fine-tuning a pretrained model, rather than training from scratch, +because the latter is way more computationally expensive. + +When performing fine-tuning, the following procedure is preferred. + +## Recommended Procedure — Generate a force field via fine-tuning pretrained-model + + +### Stage Zero — Ask the user: Do you have a DFT-labelled dataset? + +A "DFT-labeled dataset" means structures whose energy, forces, and virial +were computed by DFT (VASP, ABACUS, etc.), **not** by a pretrained machine-learning model. + +> **Key principle:** The pretrained model is only a **surrogate for structural-space exploration** +> via molecular dynamics (MD), **not a ground truth**. The target that fine-tuning aims to match must be DFT data. +> All ground-truth labels used for fine-tuning and evaluation must come from **DFT calculations**. + +- **Bench mode** (`agent_mode == "bench"`): skip this question — assume NO dataset and + proceed directly to the "NO dataset" branch below. + +- **If the user HAS a DFT-labeled dataset:** +Proceed directly to Stage B below. + +- **If the user has NO DFT-labeled dataset:** + +Follow Stages A–C below. + + +### Stage A — Generate candidate structures for labeling via structure exploration + +1. **Classify the problem complexity:** + - **Simple systems** — bulk crystals, random alloys, simple compounds. + - **Complex systems** — defects, dopants, surfaces, interfaces, transition states, + high-entropy alloys, amorphous structures, etc. Also treat the system as complex when + the provided initial structures span **multiple distinct cell types** (e.g. different + Bravais lattices or coordination environments). + +2. **For simple systems:** proceed directly to step 4 below. + +3. **For complex systems: ask the user if they already have initial structure files.** + If yes, use the user's structures as the starting point. If no, generate an intial structure + (or multiple initial structures, if needed) using the `atomic-structure` skill + (or `matcraft-kit` for surfaces/defects). + +4. **Choose simulation cell size for MD**: According to the following rules, determine whether the + initial structures need to be replicated into supercells. Do supercell operations only if needed, + and perform it only **ONCE** in the entire workflow. + + > **Rules for judging MD simulation cell size:** + > Keep each structure at roughly **50 atoms** when possible. + > For systems exceeding this size, + > do NOT perform supercell operations — use the original cell as-is. + +5. **Generate candidate structures** for MD exploration: + - Refer to the `ase` skill for details of using ASE. + - Use the resulting structure (or structures) from step 4 as the starting simulation cell (ase.Atoms). + - Use the pretrained model to set the simulation cell's calculator. + - Relax the structure (optimize both atomic coordinates and lattice vectors) first to avoid MD collapse. + - Explore configuration space via **NPT-ensamble MD**. + - **MD sampling skill choice:** `ase` >> `lammps`. Try `ase` first; + if it fails repeatedly, switch to `lammps`. Never use `atomic-structure` for MD. + + - **MD sampling parameters (NPT ensemble):** + + Adjust the following parameters according to the table below and the specific needs of the system. + + | Parameter | Default value | Description | + |---------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| + | Ensemble | **NPT** | NPT ensemble is mandatory for structure exploration | + | Temperature | **300 K, 600 K, 900 K** | Target temperatures. Use 300K, 600K, 900K as default. Adjust to user needs. For solid-state materials, **approach but never exceed the melting point**! | | + | Pressure | **1 bar, 10 Gpa** | Target pressure. For regular conditions, try from 1 bar and 10 GPa; adjust to user needs. | + | Step size | **2 fs** | Highest safe step size, decrease to 1 fs above 2000 K or when unstable (volume explosion) | + | Structure saving interval | Every **5** steps | Recommend to have at least **10 fs** spacing between two saved frames to have enough variation between structures. | + | Duration | **10 ps** | Total simulation time per temperature and per pressure | + | Output frames | **100** | Number of MD frames to retain from all temperatures and pressure samples. 100 is default. For more complex systems, use up to 500. | + + > Output frames recommendation: + > - **100** for simple systems (bulk crystals, random alloys, simple compounds) + > - **200** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) + > - **500** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) + +6. **Entropy-based structure selection (MANDATORY)** + After MD sampling, use entropy-based filtering to select a subset of 50% of the structures **with diversity** + from the obtained MD frames before DFT labeling to reduce DFT cost. For example: + ``` + run_skill_script( + skill_name="quests", + script_name="active_learning.py", + args="filter-by-entropy md_trajectory.extxyz --max-sel 50 --chunk-size 10" + ) + ``` + `chunk-size` had better be 1/50 of the total number of MD frames, but never below 10. + + > **CRITICAL:** Always run entropy-based selection BEFORE DFT labeling. Never send + > all sampled frames directly to DFT — use the selected structures instead. + + +### Stage B — DFT labeling + +Run DFT single-point calculations on the **selected structures** to obtain energy, +force, and virial labels. + +- Use the `vasp` or `abacus` skill for DFT input preparation and execution (`vasp` preferred). +- See `concepts/dft-calculation` for guidance on choosing a DFT code. +- Job submission is handled by the `bohrium` skill. + + +### Stage C — Fine-tuning & Evaluation + +> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**. + +1. Create the fresh workdir, and prepare input files in the fine-tuning workdir. For example, + for DPA models, you may run the script [deepmd/scripts/deepmd_prepare.py](../../../deepmd/scripts/deepmd_prepare.py) + under the `deepmd` skill. + In this preparation stage, train/test split is performed. + Recommended train vs test split ratio is **4:1** for all DFT-labeled frames. + +2. Submit finetune job on Bohrium via the `bohrium` skill . + + 3. **Evaluate:** + Perform testing to obtain predicted energy (and per-atom energy), forces, virials (and per-atom virials) or + stress, then compute MAE errors. Also, perform such evaluation with the original pretrained model for comparison + with the fine-tuned model. + > For **DPA models**, the evaluation of both the pretrained and fine-tuned models are already taken care of + > by the commands generated + > with script [deepmd/scripts/deepmd_prepare.py](../../../deepmd/scripts/deepmd_prepare.py), therefore the evaluation + > results will come back together with the fine-tuned model. + + > For **other MLFF models**, you may need to manually run the evaluation through the MLFF's native ase calculator interface. + > Refer to `ase` skill for guidance. + +4. When the system of your study used very different first-principle computation settings from the training set + of your pretrained model, energy MAE may not be comparable between the pretrained and fine-tuned models as + the zero point of energy may be different. In this case, you may need to adjust the energy bias of the pretrained + model for rational comparison. You may perform a quick adjustment like the following: + ```python + e_shift = np.mean(all_e_peratom_dft - all_e_peratom_predicted) + ``` + Then do: + ```python + get_mae( + all_e_peratom_dft, + all_e_peratom_predicted + e_shift + ), + ``` + to get comparable energy MAE. + +5. **Report and compare the results:** + - Pretrained: energy per atom MAE = X, force MAE = Y + - Finetuned: energy per atom MAE = X', force MAE = Y' + - Improvement: energy per atom MAE reduced by Z%, force MAE reduced by W% diff --git a/src/matcreator/skills/deepmd/SKILL.md b/src/matcreator/skills/deepmd/SKILL.md index 41c879ef..39300ee0 100644 --- a/src/matcreator/skills/deepmd/SKILL.md +++ b/src/matcreator/skills/deepmd/SKILL.md @@ -136,6 +136,24 @@ dp show model-branch dp show descriptor ``` +--- + +# DPA-4c distillation (student model training) + +Distilling a student model from a fine-tuned DPA-4 teacher is the only scenario where +training a model is advised (see the description of this skill). The student architecture +for distillation is **DPA-4c** (`descriptor.type = "dpa4c"`), whose CLI usage differs +from regular fine-tuning. + +> **Prerequisite gate:** distillation requires the concept-level Stage A (teacher NPT MD +> exploration) and Stage B (teacher inference labeling) to be completed first — see the +> `machine-learning-force-field` skill ([references/distillation.md](../concepts/machine-learning-force-field/references/distillation.md)). +> Training directly on the seed/static structures is forbidden. + +The concrete DPA-4c training command, the verified input template, and the recommended +Bohrium image/machine are documented in +[references/supported_deepmd_models.md](references/supported_deepmd_models.md) ("DPA-4c" section). + --- # DeePMD-kit python interface (ASE calculator) diff --git a/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json b/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json new file mode 100644 index 00000000..4df6e7f5 --- /dev/null +++ b/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json @@ -0,0 +1,190 @@ +{ + "model": { + "type_map": [ + "H", + "He", + "Li", + "Be", + "B", + "C", + "N", + "O", + "F", + "Ne", + "Na", + "Mg", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe", + "Cs", + "Ba", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn", + "Fr", + "Ra", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Cn", + "Nh", + "Fl", + "Mc", + "Lv", + "Ts", + "Og" + ], + "descriptor": { + "type": "dpa4c", + "rcut": 6.0, + "channels": 64, + "lmax": 2, + "radial_modes": 0, + "use_amp": false, + "precision": "float32", + "seed": 42 + }, + "fitting_net": { + "neuron": [ + 256, + 256, + 256 + ], + "resnet_dt": false, + "activation_function": "silu", + "precision": "float32", + "seed": 42 + } + }, + "learning_rate": { + "type": "cosine", + "start_lr": 0.003, + "stop_lr": 1e-06, + "warmup_ratio": 0.003, + "warmup_start_factor": 0.2 + }, + "loss": { + "type": "ener", + "loss_func": "mae", + "f_use_norm": true, + "start_pref_e": 20, + "limit_pref_e": 20, + "start_pref_f": 20, + "limit_pref_f": 20, + "start_pref_v": 5, + "limit_pref_v": 5 + }, + "optimizer": { + "type": "HybridMuon", + "weight_decay": 0.001 + }, + "training": { + "stat_file": "stat.hdf5", + "training_data": { + "systems": "./sto_slab_train_distillation_npy", + "batch_size": "filter:92" + }, + "validation_data": { + "systems": "./sto_slab_test_distillation_npy", + "batch_size": 1, + "numb_batch": 1 + }, + "num_epochs": 50, + "enable_compile": true, + "gradient_max_norm": 5, + "save_freq": 2000, + "max_ckpt_keep": 5, + "disp_file": "lcurve.out", + "disp_freq": 1000, + "disp_training": true, + "time_training": true, + "seed": 42 + }, + "_comment": "1 GPU; initialized from ./dpa4c_pretrain_rmse_epoch.pt via CLI flag: dp --pt-expt train input.json --init-model ./dpa4c_pretrain_rmse_epoch.pt --skip-neighbor-stat (--finetune is NOT usable: its bias-adjustment dense forward OOMs for dpa4c sel=[999999])" +} diff --git a/src/matcreator/skills/deepmd/references/supported_deepmd_models.md b/src/matcreator/skills/deepmd/references/supported_deepmd_models.md index d3542ab9..e63528bf 100644 --- a/src/matcreator/skills/deepmd/references/supported_deepmd_models.md +++ b/src/matcreator/skills/deepmd/references/supported_deepmd_models.md @@ -135,3 +135,30 @@ DPA-2 and DPA-3 models are supported but not strongly recommended in any scenari 4. Never use any non-nvidia GPUs for now as they are poorly supported by deepmd-kit. 5. Also, do not use nvidia GPUs older than V100 as they no longer support the triton AOT induction route of modern pytorch, which is compulsory for deepmd-kit>=3.2.0. + +## DPA-4c ("dpa4c" descriptor, only used as student models during distillation) + +DPA-4c is the student architecture for distillation from a fine-tuned DPA-4 teacher. +Its CLI usage differs from the fine-tuning flow documented elsewhere in this skill: + +**`--finetune` is STRICTLY PROHIBITED for DPA-4c; always train from scratch with `--pt-expt`:** + +```bash +dp --pt-expt train input.json --init-model --skip-neighbor-stat +``` + +- **NEVER use `--finetune` with DPA-4c.** Its bias-adjustment dense forward pass runs out + of memory (OOM) for the DPA-4c selection `sel=[999999]`; the job will crash. Always use + `--pt-expt ... --init-model ... --skip-neighbor-stat` to train from scratch instead. +- `` is the DPA-4c pretrained checkpoint used to initialize the student + (historically `dpa4c_pretrain_rmse_epoch.pt`); `--skip-neighbor-stat` must be appended. + +**Verified input template:** [dpa4c_distill_input.json](dpa4c_distill_input.json) (in this +`references/` directory) is the input.json actually used in a historical, completed +DPA-4c distillation run. It is **exclusive to DPA-4c — do NOT use it for any other +architecture** (dpa2, dpa3, dpa4/SeZM, se_atten_v2, ...). + +**[For bohrium submission] Image and machine:** recommended image +`registry.dp.tech/dptech/dpa-calculator:dpa4-mlip-340e01f9` on a **5090** GPU machine. +This image is specific to DPA-4c distillation; all other DPA models keep the images +given in the previous section. \ No newline at end of file diff --git a/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py b/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py index c9594c7f..ae5b8654 100644 --- a/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py +++ b/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py @@ -801,10 +801,19 @@ def cmd_prepare_finetune(args) -> None: logger.info("Sub-seed %s=%d", seed_path, seed_value) # Set number of epochs. Supported since deepmd 3.2.0. Estimating number of steps no longer meaningful. - cfg["training"]["num_epoch"] = args.epochs + # NOTE: dargs alias conversion only converts the FIRST alias found, so the training + # dict must contain exactly one epoch key, otherwise the leftover alias trips the + # strict schema check in `dp train`. + if args.epochs is not None: + for _k in ("num_epochs", "numb_epoch", "numb_epochs", "num_epoch"): + cfg["training"].pop(_k, None) + cfg["training"]["numb_epoch"] = args.epochs + else: + # Keep the template default (e.g. num_epochs for DPA-4); just drop stray nulls. + cfg["training"].pop("num_epoch", None) logger.info( - "Epochs=%d, n_train=%d", - args.epochs, + "Epochs=%s, n_train=%d", + cfg["training"].get("numb_epoch", cfg["training"].get("num_epochs")), len(train_atoms), ) @@ -987,12 +996,6 @@ def _add_finetune_argparse(p: argparse.ArgumentParser) -> None: required=True, metavar="NUMTRAIN", ) - p.add_argument( - "--seed", - help="Random seed (default: None)", - type=int, - default=None - ) p.add_argument( "--model_type", help="Model type (default: None)", diff --git a/src/matcreator/skills/e2b/SKILL.md b/src/matcreator/skills/e2b/SKILL.md index 2023fe80..8bcf36f9 100644 --- a/src/matcreator/skills/e2b/SKILL.md +++ b/src/matcreator/skills/e2b/SKILL.md @@ -18,9 +18,7 @@ control the sandbox even after the agent or browser reconnects. ## Submission -1. Choose `template` explicitly for every `submit_e2b_sandbox` call. When the - template name is unknown, run `lbg sdbx template ls -q` to list available - templates. Install the command with `pip install -U --pre lbg` when needed. +1. Choose `template` explicitly for every `submit_e2b_sandbox` call. Ask for the `template` explicitly if unknown. 2. Call `submit_e2b_sandbox` once for the current step. It is idempotent for the current session, node, and template. 3. Use `upload_e2b_input` for workspace files, then use `run_e2b_command` for diff --git a/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md b/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md index dd65826a..9e8e458b 100644 --- a/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md +++ b/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md @@ -100,7 +100,7 @@ export E2B_VALIDATE_API_KEY=false ``` The adapter already sets `E2B_VALIDATE_API_KEY=false` by default at import -time (`matcreator/control_plane/e2b.py`), so this is handled for Bohrium +time (`matcreator/control_plane/providers/e2b.py`), so this is handled for Bohrium deployments out of the box. An explicit value in the environment takes precedence — set `E2B_VALIDATE_API_KEY=true` only if you are talking to the public e2b.dev API with a standard `e2b_`-prefixed key. diff --git a/src/matcreator/tools/workspace_tools.py b/src/matcreator/tools/workspace_tools.py index 18b51e6e..b23ef6db 100644 --- a/src/matcreator/tools/workspace_tools.py +++ b/src/matcreator/tools/workspace_tools.py @@ -289,6 +289,29 @@ def _execution_timeout_seconds() -> int: return timeout if timeout > 0 else _DEFAULT_EXEC_TIMEOUT_SECONDS +async def _terminate_subprocess(proc: asyncio.subprocess.Process) -> tuple[bytes, bytes]: + """Best-effort kill + pipe drain for use in exception/cleanup paths. + + Never raises: tolerates the process having already exited (uvloop raises + ``ProcessLookupError`` on a redundant ``kill()``) and tolerates drain + failures, so it never masks the original exception (TimeoutError / + CancelledError) raised by the caller. + + Returns whatever ``(stdout, stderr)`` could be drained; values may be + ``b""`` when the drain did not complete. + """ + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + # Process exited between the returncode check and kill() + pass + try: + return await proc.communicate() + except (Exception, asyncio.CancelledError): + return b"", b"" + + async def run_python(code: str, tool_context: ToolContext) -> str: """Execute a Python code snippet and return its stdout/stderr. @@ -316,12 +339,10 @@ async def run_python(code: str, tool_context: ToolContext) -> str: timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") @@ -357,12 +378,10 @@ async def run_bash(script: str, tool_context: ToolContext) -> str: timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") @@ -396,12 +415,10 @@ async def run_python_file(relative_path: str) -> str: timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") @@ -489,12 +506,10 @@ async def run_skill_script( timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") diff --git a/tests/test_bohr_job_adapter.py b/tests/test_bohr_job_adapter.py new file mode 100644 index 00000000..3f91dccb --- /dev/null +++ b/tests/test_bohr_job_adapter.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import json +import subprocess + +import pytest + +from matcreator.control_plane.providers._bohr_cli import BohrCLIError +from matcreator.control_plane.providers.base import RemoteJobCapability +from matcreator.control_plane.providers.bohr_job import BohrJobAdapter + + +class _FakeCompleted: + def __init__(self, stdout: str, returncode: int = 0, stderr: str = "") -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def _ok(data) -> str: + return json.dumps({"ok": True, "data": data}) + + +def _err(message: str) -> str: + return json.dumps({"ok": False, "error": {"message": message}}) + + +def test_create_submits_job_and_extracts_bohr_id(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok({"bohrId": 20543207, "id": 23197091})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + external_id = adapter.create( + { + "project_id": 42, + "job_name": "relax-job", + "machine_type": "c8_m32_cpu", + "image_address": "registry.dp.tech/dptech/vasp:5.4.4", + "command": "vasp_std", + "input_directory": "./input", + "max_run_time": 60, + } + ) + + assert external_id == "20543207" + command = captured["command"] + assert command[1:4] == ["job", "submit", "--project_id"] + assert "42" in command + assert "--input_directory" in command and "./input" in command + assert "--max_run_time" in command and "60" in command + assert "-o" in command and "json" in command + assert "--no-interactive" in command + assert "-y" in command + + +def test_create_requires_all_fields() -> None: + adapter = BohrJobAdapter() + + with pytest.raises(ValueError, match="job_name"): + adapter.create({"project_id": 1}) + + +def test_status_maps_phase_to_normalized_status(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"phase": "completed", "terminal": True})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + status = adapter.status("20543207") + + assert status.normalized_status == "succeeded" + assert status.snapshot == {"phase": "completed", "terminal": True} + assert status.error is None + + +def test_status_maps_failed_phase_and_captures_error_info(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"phase": "failed", "terminal": True, "errorInfo": "Command not found."})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + status = adapter.status("20543187") + + assert status.normalized_status == "failed" + assert status.error == "Command not found." + + +def test_status_maps_stopped_phase_to_cancelled(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"phase": "stopped", "terminal": True})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + status = adapter.status("20539094") + + assert status.normalized_status == "cancelled" + + +def test_status_raises_bohr_cli_error_on_failure(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_err("record not found")) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + with pytest.raises(BohrCLIError, match="record not found"): + adapter.status("nonexistent") + + +def test_cancel_invokes_terminate_with_no_wait(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + adapter.cancel("20543207") + + command = captured["command"] + assert command[1:5] == ["job", "terminate", "--id", "20543207"] + assert "--no-wait" in command + + +def test_collect_outputs_downloads_and_lists_files(monkeypatch, tmp_path) -> None: + def fake_run(command, **kwargs): + # Simulate the download command producing files in the destination. + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + (dest / "OUTCAR").write_text("data") + (dest / "vasprun.xml").write_text("data") + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + artifacts = adapter.collect_outputs("20543207", tmp_path / "out") + + sources = {artifact["source"] for artifact in artifacts} + destinations = {artifact["destination"] for artifact in artifacts} + assert sources == {"20543207"} + assert destinations == {str(tmp_path / "out" / "OUTCAR"), str(tmp_path / "out" / "vasprun.xml")} + + +def test_capabilities_are_batch_collect_only() -> None: + adapter = BohrJobAdapter() + + assert adapter.capabilities == frozenset({RemoteJobCapability.BATCH_COLLECT}) + assert adapter.provider == "bohr_job" diff --git a/tests/test_bohr_sandbox_adapter.py b/tests/test_bohr_sandbox_adapter.py new file mode 100644 index 00000000..3c541935 --- /dev/null +++ b/tests/test_bohr_sandbox_adapter.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import subprocess + +import pytest + +from matcreator.control_plane.providers._bohr_cli import BohrCLIError +from matcreator.control_plane.providers.base import RemoteJobCapability +from matcreator.control_plane.providers.bohr_sandbox import BohrSandboxAdapter + + +class _FakeCompleted: + def __init__(self, stdout: str, returncode: int = 0, stderr: str = "") -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def _ok(data) -> str: + return json.dumps({"ok": True, "data": data}) + + +def _err(message: str) -> str: + return json.dumps({"ok": False, "error": {"message": message}}) + + +def test_create_builds_command_and_extracts_sandbox_id(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + # Real `bohr sandbox create -o json` payload shape (CLI 2.6.15): + # the ID key is "sandboxID", not "sandbox_id". + return _FakeCompleted( + _ok( + { + "sandboxID": "default--sdbxdefault-abc12", + "templateID": "sdbxagent", + "state": "running", + "domain": "bohr-sandbox.bohrium.com", + } + ) + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + sandbox_id = adapter.create( + { + "project_id": 1234, + "template": "sdbxagent", + "timeout": 3600, + "env": {"FOO": "bar"}, + } + ) + + assert sandbox_id == "default--sdbxdefault-abc12" + command = captured["command"] + assert command[1:3] == ["sandbox", "create"] + assert "--template" in command and "sdbxagent" in command + assert "--project-id" in command and "1234" in command + assert "--timeout" in command and "3600" in command + assert "--env" in command and "FOO=bar" in command + + +def test_create_requires_template() -> None: + adapter = BohrSandboxAdapter() + + with pytest.raises(ValueError, match="template"): + adapter.create({"project_id": 1234}) + + +def test_create_accepts_snake_case_sandbox_id_spelling(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"sandbox_id": "default--sdbxdefault-abc12"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert BohrSandboxAdapter().create( + {"project_id": 1, "template": "sdbxagent"} + ) == "default--sdbxdefault-abc12" + + +def test_create_requires_project_id() -> None: + adapter = BohrSandboxAdapter() + + with pytest.raises(ValueError, match="project_id"): + adapter.create({}) + + +def test_create_raises_when_no_sandbox_id_returned(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"state": "running", "templateID": "sdbxagent"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + with pytest.raises(BohrCLIError, match=r"did not return a sandbox ID.*state.*templateID"): + adapter.create({"project_id": 1, "template": "sdbxagent"}) + + +def test_status_reports_liveness_without_normalized_status_by_default(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"status": "running"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + status = adapter.status("sbx-1") + + assert status.normalized_status is None + assert status.snapshot["provider_status"] == "reachable" + + +def test_status_maps_unreachable_states_to_lost(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"status": "terminated"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + status = adapter.status("sbx-1") + + assert status.normalized_status == "lost" + assert status.snapshot["provider_status"] == "unreachable" + + +def test_cancel_invokes_delete_with_force(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + adapter.cancel("sbx-1") + + command = captured["command"] + assert command[1:4] == ["sandbox", "delete", "sbx-1"] + assert "--force" in command + + +def test_run_command_parses_stdout_stderr_exit_code(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + captured["kwargs"] = kwargs + return _FakeCompleted(_ok({"stdout": "hello\n", "stderr": "", "exit_code": 0})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + result = adapter.run_command("sbx-1", "echo hello") + + assert result == {"stdout": "hello\n", "stderr": "", "exit_code": 0} + command = captured["command"] + assert command[1:5] == ["sandbox", "exec", "sbx-1", "--command"] + assert "echo hello" in command + + +def test_run_command_disables_both_cli_and_subprocess_timeouts(monkeypatch) -> None: + """`bohr sandbox exec` caps a command at 90s by default; a long-running + remote computation must not be silently truncated, matching the E2B + adapter's unbounded `timeout=0` command semantics.""" + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + captured["kwargs"] = kwargs + return _FakeCompleted(_ok({"stdout": "", "stderr": "", "exit_code": 0})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + adapter.run_command("sbx-1", "sleep 300") + + command = captured["command"] + timeout_index = command.index("--timeout") + assert command[timeout_index + 1] == "0" + assert captured["kwargs"]["timeout"] is None + + +def test_upload_file_rejects_missing_source(tmp_path) -> None: + adapter = BohrSandboxAdapter() + + with pytest.raises(FileNotFoundError): + adapter.upload_file("sbx-1", tmp_path / "missing.txt", "/home/user/missing.txt") + + +def test_upload_file_invokes_files_write(monkeypatch, tmp_path) -> None: + source = tmp_path / "input.txt" + source.write_text("hello") + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + adapter.upload_file("sbx-1", source, "/home/user/input.txt") + + command = captured["command"] + assert command[1:5] == ["sandbox", "files", "write", "sbx-1"] + assert "/home/user/input.txt" in command + assert "--source" in command and str(source) in command + + +def test_download_file_invokes_files_read_and_creates_parent_dir(monkeypatch, tmp_path) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + dest = tmp_path / "outputs" / "CHGCAR" + + result = adapter.download_file("sbx-1", "/home/user/CHGCAR", dest) + + assert result == dest.resolve() + assert dest.parent.is_dir() + command = captured["command"] + assert command[1:5] == ["sandbox", "files", "read", "sbx-1"] + assert "--destination" in command and str(dest.resolve()) in command + + +def test_capabilities_have_no_pause() -> None: + adapter = BohrSandboxAdapter() + + assert adapter.capabilities == frozenset( + {RemoteJobCapability.INTERACTIVE_EXEC, RemoteJobCapability.FILE_TRANSFER} + ) + assert adapter.provider == "bohr_sandbox" diff --git a/tests/test_e2b_adapter.py b/tests/test_e2b_adapter.py index ed363c8e..b3a43cbe 100644 --- a/tests/test_e2b_adapter.py +++ b/tests/test_e2b_adapter.py @@ -5,7 +5,7 @@ import pytest -from matcreator.control_plane.e2b import E2BConfigurationError, E2BSandboxAdapter, E2BSandboxSpec +from matcreator.control_plane.providers.e2b import E2BConfigurationError, E2BSandboxAdapter, E2BSandboxSpec class _FakeResult: @@ -52,6 +52,7 @@ class _FakeSandbox: sandbox_id = "sandbox-123" created_with: dict = {} connected_to: list[str] = [] + connect_opts: list[dict] = [] paused = False killed = False files = _FakeFiles() @@ -62,14 +63,17 @@ def create(cls, **kwargs): return cls() @classmethod - def connect(cls, sandbox_id): + def connect(cls, sandbox_id, **opts): cls.connected_to.append(sandbox_id) + cls.connect_opts.append(opts) return cls() class commands: + last_command: str | None = None + @staticmethod def run(command, user, **kwargs): - assert command == "echo hello" + _FakeSandbox.commands.last_command = command assert user == "root" return _FakeResult() @@ -84,22 +88,27 @@ def kill(self): def fake_e2b_module(monkeypatch): _FakeSandbox.created_with = {} _FakeSandbox.connected_to = [] + _FakeSandbox.connect_opts = [] _FakeSandbox.paused = False _FakeSandbox.killed = False _FakeSandbox.files = _FakeFiles() monkeypatch.setitem(sys.modules, "e2b_code_interpreter", types.SimpleNamespace(Sandbox=_FakeSandbox)) + # Reconnects require the endpoint configuration in the environment. + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") def test_adapter_creates_sandbox_with_project_header() -> None: adapter = E2BSandboxAdapter() sandbox_id = adapter.create( - E2BSandboxSpec( - template="doc-compiler", - api_key="secret", - api_url="https://e2b.example", - project_id="project-42", - lifecycle={"on_timeout": "pause"}, - ) + { + "template": "doc-compiler", + "api_key": "secret", + "api_url": "https://e2b.example", + "project_id": "project-42", + "lifecycle": {"on_timeout": "pause"}, + } ) assert sandbox_id == "sandbox-123" @@ -115,12 +124,70 @@ def test_adapter_connects_for_command_and_controls() -> None: "stderr": "", "exit_code": 0, } + assert _FakeSandbox.commands.last_command == "echo hello" adapter.pause("sandbox-123") adapter.terminate("sandbox-123") assert _FakeSandbox.connected_to == ["sandbox-123", "sandbox-123", "sandbox-123"] assert _FakeSandbox.paused is True assert _FakeSandbox.killed is True + # Reconnects must carry the configured endpoint, never the SDK default. + assert _FakeSandbox.connect_opts[0] == { + "api_key": "secret", + "api_url": "https://e2b.example", + "headers": {"X-Project-Id": "project-42"}, + } + + +def test_adapter_connect_fails_loudly_without_endpoint_configuration(monkeypatch) -> None: + adapter = E2BSandboxAdapter() + monkeypatch.delenv("E2B_API_KEY", raising=False) + + with pytest.raises(E2BConfigurationError, match="E2B_API_KEY and E2B_API_URL"): + adapter.run_command("sandbox-123", "echo hello") + assert _FakeSandbox.connected_to == [] + + +def test_adapter_cancel_aliases_terminate() -> None: + adapter = E2BSandboxAdapter() + + adapter.cancel("sandbox-123") + + assert _FakeSandbox.killed is True + + +def test_adapter_create_error_includes_request_context(monkeypatch) -> None: + def _boom(**kwargs): + raise RuntimeError("404: Resource not found") + + monkeypatch.setattr(_FakeSandbox, "create", _boom) + adapter = E2BSandboxAdapter() + + with pytest.raises(RuntimeError) as excinfo: + adapter.create( + { + "template": "doc-compiler", + "api_key": "secret", + "api_url": "https://open.bohrium.com/wrong/path", + "project_id": "project-42", + } + ) + + message = str(excinfo.value) + assert "404: Resource not found" in message + assert "https://open.bohrium.com/wrong/path" in message + assert "doc-compiler" in message + assert "project-42" in message + assert "secret" not in message + + +def test_adapter_status_reports_liveness_without_a_normalized_status() -> None: + adapter = E2BSandboxAdapter() + + status = adapter.status("sandbox-123") + + assert status.normalized_status is None + assert status.snapshot["provider_status"] == "reachable" def test_adapter_download_file_streams_to_local_destination(tmp_path) -> None: diff --git a/tests/test_e2b_tools.py b/tests/test_e2b_tools.py deleted file mode 100644 index 75839cea..00000000 --- a/tests/test_e2b_tools.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from matcreator.agents.execution_agent import e2b_tools -from matcreator.control_plane.remote_job_service import E2BConnectionConfig - - -class _FakeService: - def __init__(self) -> None: - self.submissions: list[dict] = [] - self.store = self - - def submit_e2b(self, **kwargs): - self.submissions.append(kwargs) - return { - "job_id": "job-123", - "status": "running", - "external_id": "sandbox-123", - } - - def get_job(self, job_id: str): - if job_id != "job-123": - return None - return { - "job_id": job_id, - "owner_id": "alice", - "session_id": "session-1", - "status": "running", - "external_id": "sandbox-123", - "snapshot": {}, - "error": None, - "updated_at": 1, - } - - def list_events(self, job_id: str): - return [{"event_type": "user_control", "payload": {"action": "terminate", "source": "ui"}}] - - def pause_e2b(self, job_id: str): - return {"job_id": job_id, "status": "paused", "external_id": "sandbox-123"} - - def terminate_e2b(self, job_id: str): - return {"job_id": job_id, "status": "terminated", "external_id": "sandbox-123"} - - def run_e2b_command(self, job_id: str, command: str, *, user: str): - return {"stdout": f"ran {command}", "stderr": "", "exit_code": 0} - - def upload_e2b_file(self, job_id: str, source, destination: str): - return {"source": str(source), "destination": destination} - - def download_e2b_file(self, job_id: str, source: str, destination: str): - return {"source": source, "destination": str(destination)} - - -def _context(): - return SimpleNamespace( - state={ - "session_id": "session-1", - "_graph_exec_node_id": "execution_0__node_relax", - "step_number": 2, - }, - _invocation_context=SimpleNamespace(user_id="alice"), - ) - - -def test_submit_e2b_tool_uses_current_session_and_node(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - monkeypatch.setenv("E2B_API_KEY", "secret") - monkeypatch.setenv("E2B_API_URL", "https://e2b.example") - monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") - - result = e2b_tools.submit_e2b_sandbox(_context(), timeout=120, template="doc-compiler") - - assert result == { - "status": "running", - "job_id": "job-123", - "sandbox_id": "sandbox-123", - "message": "Tracked E2B sandbox is ready. Use its job_id for status or controls.", - } - submission = service.submissions[0] - assert submission["owner_id"] == "alice" - assert submission["session_id"] == "session-1" - assert submission["node_id"] == "relax" - assert submission["step_number"] == 2 - assert submission["connection"] == E2BConnectionConfig( - api_key="secret", - api_url="https://e2b.example", - project_id="project-42", - template="doc-compiler", - ) - - -def test_submit_e2b_tool_requires_explicit_template(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - - result = e2b_tools.submit_e2b_sandbox(_context()) - - assert result["status"] == "error" - assert "template is required" in result["message"] - assert service.submissions == [] - - -def test_e2b_connection_uses_configured_environment_names(monkeypatch) -> None: - monkeypatch.setenv("E2B_API_KEY", "access-key") - monkeypatch.setenv("E2B_API_URL", "https://e2b.example") - monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-7") - - connection = e2b_tools._connection() - - assert connection == E2BConnectionConfig( - api_key="access-key", - api_url="https://e2b.example", - project_id="project-7", - template="", - ) - - -def test_e2b_tools_reject_jobs_from_another_session(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() - context._invocation_context.user_id = "bob" - - assert e2b_tools.get_e2b_job_status("job-123", context) == { - "status": "error", - "message": "E2B job was not found in this session.", - } - - -def test_e2b_status_exposes_user_sandbox_control(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - - status = e2b_tools.get_e2b_job_status("job-123", _context()) - - assert status["user_control"] == {"action": "terminate", "source": "ui"} - - -def test_e2b_command_and_workspace_upload_are_scoped_to_owned_job(tmp_path, monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() - context.state["workspace_dir"] = str(tmp_path) - source = tmp_path / "input.txt" - source.write_text("input", encoding="utf-8") - - assert e2b_tools.run_e2b_command("job-123", "echo hello", context) == { - "stdout": "ran echo hello", "stderr": "", "exit_code": 0 - } - assert e2b_tools.upload_e2b_input("job-123", "input.txt", "/home/user/input.txt", context) == { - "source": str(source), "destination": "/home/user/input.txt" - } - assert e2b_tools.upload_e2b_input("job-123", "/tmp/outside.txt", "/tmp/outside.txt", context)["status"] == "error" - - -def test_download_e2b_output_is_scoped_to_workspace(tmp_path, monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() - context.state["workspace_dir"] = str(tmp_path) - destination = tmp_path / "outputs" / "CHGCAR" - - result = e2b_tools.download_e2b_output( - "job-123", "/home/user/CHGCAR", str(destination), context - ) - assert result == {"source": "/home/user/CHGCAR", "destination": str(destination.resolve())} - - assert e2b_tools.download_e2b_output( - "job-123", "/home/user/CHGCAR", "/tmp/outside.txt", context - )["status"] == "error" - - -def test_download_e2b_output_rejects_missing_workspace_dir(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() # no workspace_dir set - - result = e2b_tools.download_e2b_output( - "job-123", "/home/user/CHGCAR", "outputs/CHGCAR", context - ) - assert result["status"] == "error" - assert "workspace_dir" in result["message"] \ No newline at end of file diff --git a/tests/test_execution_grouping.py b/tests/test_execution_grouping.py new file mode 100644 index 00000000..cc8943b4 --- /dev/null +++ b/tests/test_execution_grouping.py @@ -0,0 +1,129 @@ +import unittest + +from pydantic import ValidationError + +from agents.MatCreator.agents.execution_agent.agent import ( + build_execution_groups, + build_execution_waves, +) +from agents.MatCreator.agents.execution_agent.step_executor import StepExecutorInput + + +class _DummyToolContext: + def __init__(self, state: dict): + self.state = state + + +class TestExecutionGrouping(unittest.TestCase): + def test_groups_consecutive_same_skill(self) -> None: + ctx = _DummyToolContext( + { + "current_step_index": 0, + "plan": { + "steps": [ + {"step_number": 1, "skill": "vasp", "action": "Prepare input files."}, + {"step_number": 2, "skill": "vasp", "action": "Run relaxation."}, + {"step_number": 3, "skill": "plot", "action": "Plot total energy."}, + ] + }, + } + ) + + result = build_execution_groups(ctx) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["groups"]), 2) + self.assertEqual(result["groups"][0]["step_numbers"], [1, 2]) + self.assertEqual(result["groups"][0]["skill_name"], "vasp") + self.assertEqual(result["groups"][1]["step_numbers"], [3]) + self.assertEqual(result["groups"][1]["skill_name"], "plot") + + def test_dependency_marker_starts_new_group(self) -> None: + ctx = _DummyToolContext( + { + "current_step_index": 0, + "plan": { + "steps": [ + {"step_number": 1, "skill": "vasp", "action": "Run static calculation."}, + { + "step_number": 2, + "skill": "vasp", + "action": "Using previous step results, extract DOS.", + }, + ] + }, + } + ) + + result = build_execution_groups(ctx) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["groups"]), 2) + self.assertEqual(result["groups"][0]["step_numbers"], [1]) + self.assertEqual(result["groups"][1]["step_numbers"], [2]) + + def test_build_execution_waves_parallelizes_distinct_skills(self) -> None: + groups = [ + { + "group_id": "group_1_2", + "skill_name": "vasp", + "step_numbers": [1, 2], + "actions": ["Prepare inputs.", "Run relax."], + }, + { + "group_id": "group_3_3", + "skill_name": "plot", + "step_numbers": [3], + "actions": ["Plot band structure."], + }, + ] + + result = build_execution_waves(groups) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["waves"]), 1) + self.assertEqual(len(result["waves"][0]), 2) + + def test_build_execution_waves_serializes_dependency_marked_group(self) -> None: + groups = [ + { + "group_id": "group_1_1", + "skill_name": "vasp", + "step_numbers": [1], + "actions": ["Run SCF."], + }, + { + "group_id": "group_2_2", + "skill_name": "plot", + "step_numbers": [2], + "actions": ["Using previous step results, plot DOS."], + }, + ] + + result = build_execution_waves(groups) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["waves"]), 2) + self.assertEqual(result["waves"][0][0]["group_id"], "group_1_1") + self.assertEqual(result["waves"][1][0]["group_id"], "group_2_2") + + +class TestStepExecutorInputNormalization(unittest.TestCase): + def test_legacy_fields_are_normalized(self) -> None: + payload = StepExecutorInput( + step_number=2, + action="Run calculation.", + skill_name="vasp", + workspace_dir="/tmp/work", + ) + self.assertEqual(payload.step_numbers, [2]) + self.assertEqual(payload.actions, ["Run calculation."]) + + def test_mismatched_lengths_fail_validation(self) -> None: + with self.assertRaises(ValidationError): + StepExecutorInput( + step_numbers=[1, 2], + actions=["one"], + skill_name="vasp", + workspace_dir="/tmp/work", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_execution_recovery.py b/tests/test_execution_recovery.py index a41072db..c9a2a862 100644 --- a/tests/test_execution_recovery.py +++ b/tests/test_execution_recovery.py @@ -449,6 +449,7 @@ def test_reconcile_waits_for_active_remote_job_instead_of_resubmitting(tmp_path, "provider": "e2b", "external_id": "sandbox-123", "status": "running", + "has_background_command": False, } diff --git a/tests/test_graph_logger.py b/tests/test_graph_logger.py new file mode 100644 index 00000000..ed81c976 --- /dev/null +++ b/tests/test_graph_logger.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json + +from matcreator.agents import graph_logger + + +def test_consecutive_streamed_text_is_coalesced(tmp_path, monkeypatch) -> None: + """A running node exposes one growing response instead of token cards.""" + monkeypatch.setattr(graph_logger, "ADK_DIR", tmp_path) + logger = graph_logger.AgentGraphLogger("streaming-session") + logger.log_node_start("step-one", "step", "Step one") + + logger.log_conversation_event("step-one", { + "timestamp": "first", + "author": "step_executor", + "type": "text", + "content": "Calculating lattice", + }) + # Cumulative snapshots and deltas are both emitted by supported providers. + logger.log_conversation_event("step-one", { + "timestamp": "second", + "author": "step_executor", + "type": "text", + "content": "Calculating lattice parameters", + }) + logger.log_conversation_event("step-one", { + "timestamp": "third", + "author": "step_executor", + "type": "text", + "content": " and checking convergence.", + }) + + graph_path = tmp_path / "agent_graphs" / "streaming-session.json" + graph = json.loads(graph_path.read_text(encoding="utf-8")) + conversation = graph["nodes"]["step-one"]["conversation"] + assert conversation == [{ + "timestamp": "third", + "author": "step_executor", + "type": "text", + "content": "Calculating lattice parameters and checking convergence.", + }] + + +def test_execution_batch_metadata_does_not_change_parent_edge(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(graph_logger, "ADK_DIR", tmp_path) + logger = graph_logger.AgentGraphLogger("batched-session") + logger.log_node_start("planning_0", "planning", "Planning", "orchestrator") + logger.log_node_start( + "execution_0", + "execution", + "Execution 1", + "planning_0", + batch_id="planning_0:round:0", + ) + + graph_path = tmp_path / "agent_graphs" / "batched-session.json" + graph = json.loads(graph_path.read_text(encoding="utf-8")) + assert graph["nodes"]["execution_0"]["batch_id"] == "planning_0:round:0" + assert {"from": "planning_0", "to": "execution_0"} in graph["edges"] diff --git a/tests/test_matcreator_phase_and_skills.py b/tests/test_matcreator_phase_and_skills.py new file mode 100644 index 00000000..285990a2 --- /dev/null +++ b/tests/test_matcreator_phase_and_skills.py @@ -0,0 +1,48 @@ +import unittest + +from agents.MatCreator.agent import before_agent_callback_root +from agents.MatCreator.planning_agent import planning_agent +from agents.MatCreator.prompts.workflow import get_all_workflow_types, search_skills + + +class _DummySession: + def __init__(self): + self.id = "test-session" + self.user_id = "test-user" + self.app_name = "test-app" + self.state = {} + + +class _DummyInvocationContext: + def __init__(self): + self.session = _DummySession() + + +class _DummyCallbackContext: + def __init__(self): + self._invocation_context = _DummyInvocationContext() + + +class TestMatCreatorPhaseAndSkills(unittest.TestCase): + def test_before_agent_callback_sets_default_phase(self) -> None: + callback_context = _DummyCallbackContext() + before_agent_callback_root(callback_context) + self.assertEqual(callback_context._invocation_context.session.state["phase"], "thinking") + + def test_skill_registry_loads_expected_workflows(self) -> None: + workflow_types = get_all_workflow_types() + self.assertIn("default", workflow_types) + self.assertIn("pfd", workflow_types) + + def test_skill_search_returns_matching_workflow(self) -> None: + results = search_skills("fine-tune distillation active learning", workflow_type="pfd", top_k=2) + self.assertGreaterEqual(len(results), 1) + self.assertEqual(results[0].workflow_type, "pfd") + + def test_planning_agent_has_toolized_subagents(self) -> None: + tools = getattr(planning_agent, "tools", []) or [] + self.assertGreaterEqual(len(tools), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_remote_job_monitor.py b/tests/test_remote_job_monitor.py index ced335e1..1ba0e0e7 100644 --- a/tests/test_remote_job_monitor.py +++ b/tests/test_remote_job_monitor.py @@ -2,57 +2,71 @@ import asyncio +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus from matcreator.control_plane.remote_job_monitor import RemoteJobMonitor -from matcreator.control_plane.remote_job_service import E2BConnectionConfig, RemoteJobService +from matcreator.control_plane.remote_job_service import RemoteJobService from matcreator.control_plane.remote_jobs import RemoteJobStore -class _FakeE2BAdapter: +class _FakeAdapter(RemoteJobAdapter): + provider = "e2b" + capabilities = frozenset({RemoteJobCapability.PAUSE}) + poll_interval_seconds = 1.0 + def __init__(self, *, reachable: bool = True) -> None: self.reachable = reachable self.probes: list[str] = [] - def create(self, _spec): + def create(self, spec: dict) -> str: return "sandbox-123" - def probe(self, sandbox_id: str): - self.probes.append(sandbox_id) + def status(self, external_id: str) -> RemoteJobStatus: + self.probes.append(external_id) if not self.reachable: raise RuntimeError("sandbox unavailable") - return {"provider_status": "reachable", "sandbox_id": sandbox_id} + return RemoteJobStatus(normalized_status=None, snapshot={"provider_status": "reachable", "sandbox_id": external_id}) + + def cancel(self, external_id: str) -> None: + pass + + def pause(self, external_id: str) -> None: + pass -def _create_running_job(tmp_path, adapter: _FakeE2BAdapter): +def _create_running_job(tmp_path, adapter: _FakeAdapter): store = RemoteJobStore(tmp_path / "remote-jobs.db") - service = RemoteJobService(store, e2b_adapter=adapter) - job = service.submit_e2b( + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=E2BConnectionConfig( - api_key="secret", - api_url="https://e2b.example", - project_id="project-42", - template="doc-compiler", - ), + spec={ + "template": "doc-compiler", + "api_key": "secret", + "api_url": "https://e2b.example", + "project_id": "project-42", + }, ) return store, service, job def test_monitor_reconciles_running_job_after_restart(tmp_path) -> None: - adapter = _FakeE2BAdapter() + adapter = _FakeAdapter() store, service, job = _create_running_job(tmp_path, adapter) monitor = RemoteJobMonitor(store, service, interval_seconds=1) updates = asyncio.run(monitor.reconcile_once()) assert [item["job_id"] for item in updates] == [job["job_id"]] - assert adapter.probes == ["sandbox-123"] + # One status() call happens inside submit_job itself (initial probe), one + # more from the explicit reconcile_once() call above. + assert adapter.probes == ["sandbox-123", "sandbox-123"] assert store.get_job(job["job_id"])["snapshot"]["provider_status"] == "reachable" def test_monitor_backs_off_unreachable_job_and_skips_paused_jobs(tmp_path) -> None: - adapter = _FakeE2BAdapter(reachable=False) + adapter = _FakeAdapter(reachable=False) store, service, job = _create_running_job(tmp_path, adapter) monitor = RemoteJobMonitor(store, service, interval_seconds=1, max_backoff_seconds=4) @@ -61,8 +75,53 @@ def test_monitor_backs_off_unreachable_job_and_skips_paused_jobs(tmp_path) -> No assert first[0]["snapshot"]["provider_status"] == "unreachable" assert second == [] - assert adapter.probes == ["sandbox-123"] paused = store.transition_job(job["job_id"], "pause_requested") store.transition_job(job["job_id"], "paused", expected_revision=paused["state_revision"]) monitor._next_due.clear() - assert asyncio.run(monitor.reconcile_once()) == [] \ No newline at end of file + assert asyncio.run(monitor.reconcile_once()) == [] + + +def test_monitor_reconciles_jobs_across_multiple_providers(tmp_path) -> None: + """A batch-style provider with a longer poll interval is reconciled the + same way as an interactive one — the monitor never branches on provider + name, only on each adapter's declared poll_interval_seconds.""" + + class _BatchAdapter(RemoteJobAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + poll_interval_seconds = 60.0 + + def __init__(self) -> None: + self.probes: list[str] = [] + + def create(self, spec: dict) -> str: + return "bohr-1" + + def status(self, external_id: str) -> RemoteJobStatus: + self.probes.append(external_id) + return RemoteJobStatus(normalized_status=None, snapshot={"phase": "running"}) + + def cancel(self, external_id: str) -> None: + pass + + e2b_adapter = _FakeAdapter() + batch_adapter = _BatchAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"e2b": e2b_adapter, "bohr_job": batch_adapter}) + e2b_job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", + spec={"template": "t", "api_key": "k", "api_url": "u", "project_id": "p"}, + ) + batch_job = service.submit_job( + owner_id="alice", session_id="session-1", provider="bohr_job", + idempotency_key="session-1:node-2:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + + monitor = RemoteJobMonitor(store, service, interval_seconds=1) + updates = asyncio.run(monitor.reconcile_once()) + + reconciled_ids = {item["job_id"] for item in updates} + assert reconciled_ids == {e2b_job["job_id"], batch_job["job_id"]} + assert monitor._next_due[batch_job["job_id"]] > monitor._next_due[e2b_job["job_id"]] diff --git a/tests/test_remote_job_provider_registry.py b/tests/test_remote_job_provider_registry.py new file mode 100644 index 00000000..40fa5a58 --- /dev/null +++ b/tests/test_remote_job_provider_registry.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import subprocess + +import pytest + +from matcreator.control_plane.providers import registry +from matcreator.control_plane.providers._bohr_cli import BohrCLIError, run_bohr_json +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobStatus + + +class _FakeCompleted: + def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0) -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def test_run_bohr_json_raises_on_missing_binary(monkeypatch) -> None: + def fake_run(command, **kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="not installed"): + run_bohr_json(["job", "list"]) + + +def test_run_bohr_json_raises_on_timeout(monkeypatch) -> None: + def fake_run(command, **kwargs): + raise subprocess.TimeoutExpired(cmd=command, timeout=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="timed out"): + run_bohr_json(["job", "list"], timeout=1) + + +def test_run_bohr_json_raises_on_empty_output(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(stdout="", stderr="permission denied", returncode=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="permission denied"): + run_bohr_json(["job", "list"]) + + +def test_run_bohr_json_raises_on_non_json_output(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(stdout="not json") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="non-JSON"): + run_bohr_json(["job", "list"]) + + +def test_run_bohr_json_returns_data_on_success(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(stdout='{"ok": true, "data": {"a": 1}}') + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert run_bohr_json(["job", "list"]) == {"a": 1} + + +class _DummyAdapter(RemoteJobAdapter): + provider = "dummy" + + def create(self, spec): + return "id-1" + + def status(self, external_id): + return RemoteJobStatus(normalized_status=None) + + def cancel(self, external_id): + pass + + +def test_registry_lazy_construction_and_provider_mismatch_detection(): + registry.reset_registry() + try: + calls = [] + + def factory(): + calls.append(1) + return _DummyAdapter() + + registry.register_adapter("dummy", factory) + assert calls == [] # not constructed until first get_adapter call + + adapter = registry.get_adapter("dummy") + assert isinstance(adapter, _DummyAdapter) + assert calls == [1] + + # Cached: second call does not re-invoke the factory. + registry.get_adapter("dummy") + assert calls == [1] + + with pytest.raises(KeyError): + registry.get_adapter("nonexistent") + + class _MismatchedAdapter(_DummyAdapter): + provider = "other-name" + + registry.register_adapter("mismatched", _MismatchedAdapter) + with pytest.raises(ValueError, match="reports provider"): + registry.get_adapter("mismatched") + finally: + registry.reset_registry() + # Re-register built-ins so later tests in the same process still see them. + import importlib + + import matcreator.control_plane.providers as providers_pkg + + importlib.reload(providers_pkg) diff --git a/tests/test_remote_job_service.py b/tests/test_remote_job_service.py index febc81e0..cd740882 100644 --- a/tests/test_remote_job_service.py +++ b/tests/test_remote_job_service.py @@ -1,65 +1,124 @@ from __future__ import annotations -from matcreator.control_plane.remote_job_service import E2BConnectionConfig, RemoteJobService +import base64 +import re + +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus +from matcreator.control_plane.remote_job_service import RemoteJobService from matcreator.control_plane.remote_jobs import RemoteJobStore -class _FakeE2BAdapter: +class _FakeAdapter(RemoteJobAdapter): + """Fake adapter conforming to the provider protocol, used via adapter_overrides. + + Declares every optional capability by default so one fake can cover the + submit/pause/terminate/command/upload/download surface; a test can shrink + ``capabilities`` to exercise CapabilityError handling. + """ + + provider = "e2b" + capabilities = frozenset( + { + RemoteJobCapability.PAUSE, + RemoteJobCapability.INTERACTIVE_EXEC, + RemoteJobCapability.FILE_TRANSFER, + } + ) + def __init__(self) -> None: - self.created_specs = [] - self.paused = [] - self.terminated = [] + self.created_specs: list[dict] = [] + self.paused: list[str] = [] + self.cancelled: list[str] = [] self.on_run = None + self.files: dict[str, bytes] = {} + self.launched_commands: list[str] = [] - def create(self, spec): + def create(self, spec: dict) -> str: self.created_specs.append(spec) return "sandbox-123" - def pause(self, sandbox_id: str) -> None: - self.paused.append(sandbox_id) + def status(self, external_id: str) -> RemoteJobStatus: + return RemoteJobStatus(normalized_status=None, snapshot={"provider_status": "reachable"}) + + def cancel(self, external_id: str) -> None: + self.cancelled.append(external_id) - def terminate(self, sandbox_id: str) -> None: - self.terminated.append(sandbox_id) + def pause(self, external_id: str) -> None: + self.paused.append(external_id) - def run_command(self, sandbox_id: str, command: str, *, user: str) -> dict: + def run_command(self, external_id: str, command: str, *, user: str = "root") -> dict: if self.on_run: self.on_run() + # Minimal shell simulation covering the three wrapper patterns + # RemoteJobService.start_job_command/poll_job_command construct, so + # tests can exercise them without a real shell. + launch = re.search( + r"rm -f (\S+); nohup sh -c 'echo (\S+) \| base64 -d \| sh; echo \$\? > \S+' > (\S+) 2>&1", + command, + ) + if launch: + exit_path, payload, log_path = launch.groups() + self.launched_commands.append(base64.b64decode(payload).decode("utf-8")) + self.files.pop(exit_path, None) + self.files.setdefault(log_path, b"") + return {"stdout": "LAUNCHED\n", "stderr": "", "exit_code": 0} + check = re.match(r"if \[ -f (\S+) \]; then echo DONE:\$\(cat \S+\); else echo RUNNING; fi", command) + if check: + exit_path = check.group(1) + if exit_path in self.files: + code = self.files[exit_path].decode("utf-8").strip() + return {"stdout": f"DONE:{code}\n", "stderr": "", "exit_code": 0} + return {"stdout": "RUNNING\n", "stderr": "", "exit_code": 0} + tail = re.match(r"tail -c (\d+) (\S+)", command) + if tail: + n, log_path = tail.groups() + data = self.files.get(log_path, b"") + return {"stdout": data[-int(n):].decode("utf-8", errors="replace"), "stderr": "", "exit_code": 0} return {"stdout": "", "stderr": "", "exit_code": 0} - def upload_file(self, sandbox_id: str, source, destination: str) -> None: + def upload_file(self, external_id: str, source, destination: str) -> None: self.uploads = getattr(self, "uploads", []) - self.uploads.append((sandbox_id, str(source), destination)) + self.uploads.append((external_id, str(source), destination)) - def download_file(self, sandbox_id: str, source: str, destination) -> str: + def download_file(self, external_id: str, source: str, destination, *, user: str | None = None): self.downloads = getattr(self, "downloads", []) - self.downloads.append((sandbox_id, source, str(destination))) - return str(destination) + self.downloads.append((external_id, source, str(destination))) + return destination -def _connection() -> E2BConnectionConfig: - return E2BConnectionConfig( - api_key="super-secret", - api_url="https://e2b.example", - project_id="project-42", - template="doc-compiler", - ) +def _spec() -> dict: + return { + "template": "doc-compiler", + "api_key": "super-secret", + "api_url": "https://e2b.example", + "project_id": "project-42", + "timeout": 600, + } + +def _persisted_spec() -> dict: + return {key: value for key, value in _spec().items() if key != "api_key"} -def test_submit_e2b_persists_sandbox_without_api_key_and_is_idempotent(tmp_path) -> None: - adapter = _FakeE2BAdapter() - service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), e2b_adapter=adapter) - job = service.submit_e2b( +def test_submit_job_persists_sandbox_without_api_key_and_is_idempotent(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), + persisted_specification=_persisted_spec(), ) - replay = service.submit_e2b( + replay = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), + persisted_specification=_persisted_spec(), ) assert job["status"] == "running" @@ -69,34 +128,128 @@ def test_submit_e2b_persists_sandbox_without_api_key_and_is_idempotent(tmp_path) assert len(adapter.created_specs) == 1 -def test_e2b_job_controls_update_durable_state(tmp_path) -> None: - adapter = _FakeE2BAdapter() - service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), e2b_adapter=adapter) - job = service.submit_e2b( +def test_submit_job_retries_after_a_creation_failure(tmp_path) -> None: + """A job that failed before acquiring an external ID must not poison its + + idempotency key forever: the next submission with the same key resets the + record and re-attempts provider creation.""" + + class _FlakyAdapter(_FakeAdapter): + def __init__(self) -> None: + super().__init__() + self.create_calls = 0 + + def create(self, spec: dict) -> str: + self.create_calls += 1 + if self.create_calls == 1: + raise ValueError("dictionary update sequence element #0 has length 1; 2 is required") + return super().create(spec) + + adapter = _FlakyAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + + failed = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + assert failed["status"] == "failed" + assert failed["external_id"] is None + assert "dictionary update sequence" in failed["error"] + + retried = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + assert retried["job_id"] == failed["job_id"] + assert retried["status"] == "running" + assert retried["external_id"] == "sandbox-123" + assert retried["error"] is None + assert adapter.create_calls == 2 + + +def test_submit_job_does_not_retry_a_failure_that_has_an_external_id(tmp_path) -> None: + adapter = _FakeAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), ) + store.transition_job(job["job_id"], "failed", error="provider died mid-run") - paused = service.pause_e2b(job["job_id"]) + replay = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + assert replay["status"] == "failed" + assert replay["error"] == "provider died mid-run" + assert len(adapter.created_specs) == 1 + + +def test_job_controls_update_durable_state(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + paused = service.pause_job(job["job_id"]) assert paused["status"] == "paused" assert adapter.paused == ["sandbox-123"] - terminated = service.terminate_e2b(paused["job_id"]) + terminated = service.terminate_job(paused["job_id"]) assert terminated["status"] == "terminated" - assert adapter.terminated == ["sandbox-123"] + assert adapter.cancelled == ["sandbox-123"] -def test_e2b_command_merges_telemetry_after_monitor_observation(tmp_path) -> None: - adapter = _FakeE2BAdapter() +def test_pause_job_raises_capability_error_for_pause_unsupported_provider(tmp_path) -> None: + class _NoPauseAdapter(_FakeAdapter): + capabilities = frozenset({RemoteJobCapability.INTERACTIVE_EXEC, RemoteJobCapability.FILE_TRANSFER}) + + adapter = _NoPauseAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + try: + service.pause_job(job["job_id"]) + assert False, "expected CapabilityError" + except Exception as exc: + assert "does not support 'pause'" in str(exc) + + +def test_command_merges_telemetry_after_monitor_observation(tmp_path) -> None: + adapter = _FakeAdapter() store = RemoteJobStore(tmp_path / "remote-jobs.db") - service = RemoteJobService(store, e2b_adapter=adapter) - job = service.submit_e2b( + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), ) adapter.on_run = lambda: store.record_observation( @@ -105,7 +258,7 @@ def test_e2b_command_merges_telemetry_after_monitor_observation(tmp_path) -> Non expected_revision=store.get_job(job["job_id"])["state_revision"], ) - assert service.run_e2b_command(job["job_id"], "echo done") == { + assert service.run_job_command(job["job_id"], "echo done") == { "stdout": "", "stderr": "", "exit_code": 0 } assert store.get_job(job["job_id"])["snapshot"] == { @@ -115,25 +268,205 @@ def test_e2b_command_merges_telemetry_after_monitor_observation(tmp_path) -> Non } -def test_download_e2b_file_merges_telemetry_and_returns_paths(tmp_path) -> None: - adapter = _FakeE2BAdapter() +def test_download_job_file_merges_telemetry_and_returns_paths(tmp_path) -> None: + adapter = _FakeAdapter() store = RemoteJobStore(tmp_path / "remote-jobs.db") - service = RemoteJobService(store, e2b_adapter=adapter) - job = service.submit_e2b( + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), ) dest = tmp_path / "CHGCAR" - result = service.download_e2b_file(job["job_id"], "/home/user/CHGCAR", dest) + result = service.download_job_file(job["job_id"], "/home/user/CHGCAR", dest) assert result == {"source": "/home/user/CHGCAR", "destination": str(dest.resolve())} assert adapter.downloads == [("sandbox-123", "/home/user/CHGCAR", str(dest.resolve()))] assert store.get_job(job["job_id"])["snapshot"] == { "provider_status": "reachable", - "sandbox_id": "sandbox-123", "last_download": "CHGCAR", } + +def test_reconcile_job_transitions_on_normalized_status_change(tmp_path) -> None: + class _BatchAdapter(_FakeAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + def __init__(self) -> None: + super().__init__() + self.status_calls = 0 + + def status(self, external_id: str) -> RemoteJobStatus: + # First probe (right after create, inside submit_job) reports + # "queued"; only a later explicit reconcile reports "succeeded" — + # this exercises reconcile_job's own transition, not submission. + self.status_calls += 1 + if self.status_calls == 1: + return RemoteJobStatus(normalized_status="queued", snapshot={"phase": "pending"}) + return RemoteJobStatus(normalized_status="succeeded", snapshot={"phase": "completed"}) + + adapter = _BatchAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"bohr_job": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="bohr_job", + idempotency_key="session-1:node-1:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + assert job["status"] == "queued" + + reconciled = service.reconcile_job(job["job_id"]) + assert reconciled["status"] == "succeeded" + assert reconciled["snapshot"]["phase"] == "completed" + + +def test_collect_job_outputs_is_idempotent(tmp_path) -> None: + class _BatchAdapter(_FakeAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + def __init__(self) -> None: + super().__init__() + self.collect_calls: list[str] = [] + + def status(self, external_id: str) -> RemoteJobStatus: + return RemoteJobStatus(normalized_status="succeeded", snapshot={"phase": "completed"}) + + def collect_outputs(self, external_id: str, destination_dir): + self.collect_calls.append(external_id) + return [{"source": external_id, "destination": str(destination_dir)}] + + adapter = _BatchAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"bohr_job": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="bohr_job", + idempotency_key="session-1:node-1:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + service.reconcile_job(job["job_id"]) + + collected = service.collect_job_outputs(job["job_id"], tmp_path / "out") + assert collected["status"] == "collected" + assert len(collected["artifacts"]) == 1 + assert adapter.collect_calls == ["sandbox-123"] + + replay = service.collect_job_outputs(job["job_id"], tmp_path / "out") + assert replay["status"] == "collected" + assert adapter.collect_calls == ["sandbox-123"] + + +def test_start_job_command_persists_handle_with_derived_marker_paths(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + result = service.start_job_command(job["job_id"], "sleep 300 && echo done") + + assert result["handle"]["log_path"] == f"/tmp/matcreator-cmd-{job['job_id']}.log" + assert result["handle"]["exit_path"] == f"/tmp/matcreator-cmd-{job['job_id']}.exit" + assert adapter.launched_commands == ["sleep 300 && echo done"] + persisted = service.store.get_job(job["job_id"]) + assert persisted["snapshot"]["background_command"]["log_path"] == result["handle"]["log_path"] + + +def test_start_job_command_base64_round_trips_arbitrary_shell_content(tmp_path) -> None: + """Quotes, `$()`, and backticks in the command must survive intact — + proving the wrapper can't be broken out of or reinterpreted.""" + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + tricky_command = """echo 'it'"'"'s a test' && echo "$(date)" && echo `whoami`""" + + service.start_job_command(job["job_id"], tricky_command) + + assert adapter.launched_commands == [tricky_command] + + +def test_poll_job_command_reports_running_while_no_exit_marker(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + service.start_job_command(job["job_id"], "sleep 300") + + result = service.poll_job_command(job["job_id"]) + + assert result["running"] is True + + +def test_poll_job_command_reports_result_once_finished_and_clears_handle(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + started = service.start_job_command(job["job_id"], "echo hello") + # Simulate the background command finishing inside the sandbox. + adapter.files[started["handle"]["exit_path"]] = b"0\n" + adapter.files[started["handle"]["log_path"]] = b"hello\n" + + result = service.poll_job_command(job["job_id"]) + + assert result == { + "running": False, + "exit_code": 0, + "output_tail": "hello\n", + "log_path": started["handle"]["log_path"], + } + assert service.store.get_job(job["job_id"])["snapshot"]["background_command"] is None + + +def test_poll_job_command_requires_a_started_command(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + + try: + service.poll_job_command(job["job_id"]) + assert False, "expected ValueError" + except ValueError as exc: + assert "no in-flight background command" in str(exc) + + +def test_start_job_command_raises_capability_error_for_batch_provider(tmp_path) -> None: + class _BatchAdapter(_FakeAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + adapter = _BatchAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"bohr_job": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="bohr_job", + idempotency_key="session-1:node-1:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + + try: + service.start_job_command(job["job_id"], "echo hi") + assert False, "expected CapabilityError" + except Exception as exc: + assert "does not support 'interactive_exec'" in str(exc) diff --git a/tests/test_remote_job_tools.py b/tests/test_remote_job_tools.py new file mode 100644 index 00000000..995bc383 --- /dev/null +++ b/tests/test_remote_job_tools.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from matcreator.agents.execution_agent import remote_job_tools +from matcreator.control_plane.providers.e2b import E2BConnectionConfig + + +class _FakeService: + def __init__(self) -> None: + self.submissions: list[dict] = [] + self.store = self + + def submit_job(self, **kwargs): + self.submissions.append(kwargs) + return { + "job_id": "job-123", + "status": "running", + "external_id": "sandbox-123", + } + + def get_job(self, job_id: str): + if job_id != "job-123": + return None + return { + "job_id": job_id, + "owner_id": "alice", + "session_id": "session-1", + "provider": "e2b", + "status": "running", + "external_id": "sandbox-123", + "snapshot": {}, + "error": None, + "updated_at": 1, + } + + def list_events(self, job_id: str): + return [{"event_type": "user_control", "payload": {"action": "terminate", "source": "ui"}}] + + def pause_job(self, job_id: str): + return {"job_id": job_id, "status": "paused", "external_id": "sandbox-123"} + + def terminate_job(self, job_id: str): + return {"job_id": job_id, "status": "terminated", "external_id": "sandbox-123"} + + def run_job_command(self, job_id: str, command: str, *, user: str): + return {"stdout": f"ran {command}", "stderr": "", "exit_code": 0} + + def upload_job_file(self, job_id: str, source, destination: str): + return {"source": str(source), "destination": destination} + + def download_job_file(self, job_id: str, source: str, destination: str): + return {"source": source, "destination": str(destination)} + + def collect_job_outputs(self, job_id: str, destination_dir): + return {"job_id": job_id, "status": "collected", "artifacts": [{"source": "x", "destination": str(destination_dir)}]} + + def start_job_command(self, job_id: str, command: str, *, user: str): + return {"job_id": job_id, "launch": {"stdout": "LAUNCHED\n"}, "handle": {"log_path": "/tmp/x.log", "exit_path": "/tmp/x.exit"}} + + def poll_job_command(self, job_id: str): + return {"running": False, "exit_code": 0, "output_tail": "done\n", "log_path": "/tmp/x.log"} + + +def _context(): + return SimpleNamespace( + state={ + "session_id": "session-1", + "_graph_exec_node_id": "execution_0__node_relax", + "step_number": 2, + }, + _invocation_context=SimpleNamespace(user_id="alice"), + ) + + +def test_submit_e2b_tool_uses_current_session_and_node(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + result = remote_job_tools.submit_e2b_sandbox(_context(), timeout=120, template="doc-compiler") + + assert result == { + "status": "running", + "job_id": "job-123", + "sandbox_id": "sandbox-123", + "message": "Tracked E2B sandbox is ready. Use its job_id for status or controls.", + } + submission = service.submissions[0] + assert submission["owner_id"] == "alice" + assert submission["session_id"] == "session-1" + assert submission["provider"] == "e2b" + assert submission["node_id"] == "relax" + assert submission["step_number"] == 2 + assert submission["spec"]["template"] == "doc-compiler" + assert submission["spec"]["api_key"] == "secret" + assert "api_key" not in submission["persisted_specification"] + + +def test_submit_e2b_tool_requires_explicit_template(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_e2b_sandbox(_context()) + + assert result["status"] == "error" + assert "template is required" in result["message"] + assert service.submissions == [] + + +def test_submit_e2b_tool_requires_server_configuration(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.delenv("E2B_API_KEY", raising=False) + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.delenv("BOHRIUM_PROJECT_ID", raising=False) + + result = remote_job_tools.submit_e2b_sandbox(_context(), template="doc-compiler") + + assert result["status"] == "error" + assert "E2B_API_KEY" in result["message"] + assert "BOHRIUM_PROJECT_ID" in result["message"] + assert "E2B_API_URL" not in result["message"] + assert service.submissions == [] + + +def test_submit_e2b_tool_coerces_json_string_lifecycle(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + result = remote_job_tools.submit_e2b_sandbox( + _context(), template="doc-compiler", lifecycle='{"on_timeout": "pause", "auto_resume": false}' + ) + + assert result["status"] == "running" + assert service.submissions[0]["spec"]["lifecycle"] == {"on_timeout": "pause", "auto_resume": False} + + +def test_submit_e2b_tool_rejects_non_object_lifecycle(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + for bad_lifecycle in ("pause on timeout", '["pause"]'): + result = remote_job_tools.submit_e2b_sandbox( + _context(), template="doc-compiler", lifecycle=bad_lifecycle + ) + assert result["status"] == "error" + assert "lifecycle must be a JSON object" in result["message"] + assert service.submissions == [] + + +def test_submit_e2b_tool_surfaces_failed_job_error_instead_of_claiming_ready(monkeypatch) -> None: + service = _FakeService() + + def _failed_submit(**kwargs): + service.submissions.append(kwargs) + return { + "job_id": "job-123", + "status": "failed", + "external_id": None, + "error": "e2b job creation failed: boom", + } + + service.submit_job = _failed_submit + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + result = remote_job_tools.submit_e2b_sandbox(_context(), template="doc-compiler") + + assert result["status"] == "failed" + assert result["sandbox_id"] is None + assert "e2b job creation failed: boom" in result["message"] + assert "ready" not in result["message"] + + +def test_e2b_connection_uses_configured_environment_names(monkeypatch) -> None: + monkeypatch.setenv("E2B_API_KEY", "access-key") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-7") + + connection = remote_job_tools._connection() + + assert connection == E2BConnectionConfig( + api_key="access-key", + api_url="https://e2b.example", + project_id="project-7", + template="", + ) + + +def test_submit_bohr_sandbox_tool_requires_project_id(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.delenv("BOHRIUM_PROJECT_ID", raising=False) + + result = remote_job_tools.submit_bohr_sandbox(_context()) + + assert result["status"] == "error" + assert "project_id" in result["message"] + assert service.submissions == [] + + +def test_submit_bohr_sandbox_tool_requires_explicit_template(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_bohr_sandbox(_context(), project_id=42) + + assert result["status"] == "error" + assert "template is required" in result["message"] + assert service.submissions == [] + + +def test_submit_bohr_sandbox_tool_submits_with_provider(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_bohr_sandbox(_context(), project_id=42, template="sdbxagent") + + assert result["status"] == "running" + assert result["sandbox_id"] == "sandbox-123" + assert service.submissions[0]["provider"] == "bohr_sandbox" + assert service.submissions[0]["spec"]["project_id"] == 42 + + +def test_submit_bohr_job_tool_requires_all_fields(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.delenv("BOHRIUM_PROJECT_ID", raising=False) + + result = remote_job_tools.submit_bohr_job(_context(), job_name="n") + + assert result["status"] == "error" + assert "project_id" in result["message"] + assert "machine_type" in result["message"] + assert service.submissions == [] + + +def test_submit_bohr_job_tool_submits_batch_spec(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_bohr_job( + _context(), + project_id=42, + job_name="relax-job", + machine_type="c8_m32_cpu", + image_address="registry.dp.tech/dptech/vasp:5.4.4", + command="vasp_std", + ) + + assert result["status"] == "running" + assert result["bohr_job_id"] == "sandbox-123" + submission = service.submissions[0] + assert submission["provider"] == "bohr_job" + assert submission["spec"]["command"] == "vasp_std" + + +def test_remote_job_tools_reject_jobs_from_another_session(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context._invocation_context.user_id = "bob" + + assert remote_job_tools.get_remote_job_status("job-123", context) == { + "status": "error", + "message": "Remote job was not found in this session.", + } + + +def test_remote_job_status_exposes_user_control(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + status = remote_job_tools.get_remote_job_status("job-123", _context()) + + assert status["user_control"] == {"action": "terminate", "source": "ui"} + + +def test_remote_job_command_and_workspace_upload_are_scoped_to_owned_job(tmp_path, monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context.state["workspace_dir"] = str(tmp_path) + source = tmp_path / "input.txt" + source.write_text("input", encoding="utf-8") + + assert remote_job_tools.run_remote_job_command("job-123", "echo hello", context) == { + "stdout": "ran echo hello", "stderr": "", "exit_code": 0 + } + assert remote_job_tools.upload_remote_job_input("job-123", "input.txt", "/home/user/input.txt", context) == { + "source": str(source), "destination": "/home/user/input.txt" + } + assert remote_job_tools.upload_remote_job_input( + "job-123", "/tmp/outside.txt", "/tmp/outside.txt", context + )["status"] == "error" + + +def test_download_remote_job_output_is_scoped_to_workspace(tmp_path, monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context.state["workspace_dir"] = str(tmp_path) + destination = tmp_path / "outputs" / "CHGCAR" + + result = remote_job_tools.download_remote_job_output( + "job-123", "/home/user/CHGCAR", str(destination), context + ) + assert result == {"source": "/home/user/CHGCAR", "destination": str(destination.resolve())} + + assert remote_job_tools.download_remote_job_output( + "job-123", "/home/user/CHGCAR", "/tmp/outside.txt", context + )["status"] == "error" + + +def test_download_remote_job_output_rejects_missing_workspace_dir(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() # no workspace_dir set + + result = remote_job_tools.download_remote_job_output( + "job-123", "/home/user/CHGCAR", "outputs/CHGCAR", context + ) + assert result["status"] == "error" + assert "workspace_dir" in result["message"] + + +def test_collect_remote_job_outputs_is_scoped_to_workspace(tmp_path, monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context.state["workspace_dir"] = str(tmp_path) + + result = remote_job_tools.collect_remote_job_outputs("job-123", "outputs", context) + + assert result["status"] == "collected" + assert result["artifacts"][0]["destination"] == str((tmp_path / "outputs").resolve()) + + assert remote_job_tools.collect_remote_job_outputs( + "job-123", "/tmp/outside", context + )["status"] == "error" + + +def test_start_remote_job_command_delegates_to_service(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.start_remote_job_command("job-123", "sleep 300", _context()) + + assert result["job_id"] == "job-123" + assert result["handle"]["log_path"] == "/tmp/x.log" + + +def test_start_remote_job_command_rejects_jobs_from_another_session(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context._invocation_context.user_id = "bob" + + result = remote_job_tools.start_remote_job_command("job-123", "sleep 300", context) + + assert result == { + "status": "error", + "message": "Remote job was not found in this session.", + } + + +def test_poll_remote_job_command_delegates_to_service(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.poll_remote_job_command("job-123", _context()) + + assert result == { + "running": False, + "exit_code": 0, + "output_tail": "done\n", + "log_path": "/tmp/x.log", + } + + +def test_poll_remote_job_command_surfaces_service_errors(monkeypatch) -> None: + service = _FakeService() + + def _boom(job_id): + raise ValueError("no in-flight background command") + + service.poll_job_command = _boom + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.poll_remote_job_command("job-123", _context()) + + assert result["status"] == "error" + assert "no in-flight background command" in result["message"] diff --git a/tests/test_step_executor_runner.py b/tests/test_step_executor_runner.py index 340b574e..a78e9f57 100644 --- a/tests/test_step_executor_runner.py +++ b/tests/test_step_executor_runner.py @@ -19,18 +19,23 @@ from matcreator.agents.session_log import SESSION_ARTIFACTS_KEY -def test_step_executor_registers_tracked_e2b_tools() -> None: +def test_step_executor_registers_tracked_remote_job_tools() -> None: agent = build_step_executor_agent(LLMCard(name="test", model="test-model")) tool_names = {tool.name for tool in agent.tools if hasattr(tool, "name")} assert { "submit_e2b_sandbox", - "get_e2b_job_status", - "run_e2b_command", - "upload_e2b_input", - "download_e2b_output", - "pause_e2b_sandbox", - "terminate_e2b_sandbox", + "submit_bohr_sandbox", + "submit_bohr_job", + "get_remote_job_status", + "run_remote_job_command", + "start_remote_job_command", + "poll_remote_job_command", + "upload_remote_job_input", + "download_remote_job_output", + "collect_remote_job_outputs", + "pause_remote_job", + "terminate_remote_job", } <= tool_names @@ -431,7 +436,7 @@ def test_resumed_node_receives_explicit_reattachment_instructions(): assert context is not None assert "job-1" in context assert "sbx-1" in context - assert "get_e2b_job_status" in context + assert "get_remote_job_status" in context assert "Do NOT call submit_e2b_sandbox" in context diff --git a/tests/test_structure_file_detection.py b/tests/test_structure_file_detection.py new file mode 100644 index 00000000..3f38da33 --- /dev/null +++ b/tests/test_structure_file_detection.py @@ -0,0 +1,24 @@ +"""Regression tests for recognizing conventional VASP structure filenames.""" + +import sys +from pathlib import Path + + +WEB_DIR = Path(__file__).resolve().parents[1] / "web" +if str(WEB_DIR) not in sys.path: + sys.path.insert(0, str(WEB_DIR)) + +from structure_formats import is_vasp_structure_filename + + +def test_recognizes_poscar_and_contcar_variants() -> None: + assert is_vasp_structure_filename("POSCAR") + assert is_vasp_structure_filename("POSCAR_water_layer2") + assert is_vasp_structure_filename("POSCAR-2_water6") + assert is_vasp_structure_filename("CONTCAR.relaxed") + + +def test_does_not_misidentify_unrelated_filenames() -> None: + assert not is_vasp_structure_filename("my_POSCAR_backup") + assert not is_vasp_structure_filename("POSCARwater") + assert not is_vasp_structure_filename("POSCARwater.txt") diff --git a/tests/test_web_concurrent_sessions.py b/tests/test_web_concurrent_sessions.py index c0ecf95a..16aa14b0 100644 --- a/tests/test_web_concurrent_sessions.py +++ b/tests/test_web_concurrent_sessions.py @@ -134,7 +134,129 @@ def test_stop_request_identifies_the_active_session_owner() -> None: assert "new URLSearchParams({ user_id: request.owner || state.userId })" in stop_message assert "cancel?${query}" in stop_message - assert "pollCancellationConfirmed(request.sessionId, request.owner)" in stop_message + assert "pollCancellationConfirmed(request)" in stop_message + + +def test_stop_feedback_uses_managed_run_status_and_survives_session_refresh() -> None: + content = _message_stream_js() + + assert 'content = request.stopStatus === "stopped" ? "✓ Execution stopped." : "Still executing…"' in content + assert 'fetch(`/api/runs/${encodeURIComponent(request.runId)}`)' in content + assert '["completed", "failed", "cancelled"].includes(run.status)' in content + assert "request.stopStatus = \"stopped\";\n releaseSessionRequest(request);" in content + assert "await reloadSessionSnapshot();\n // A session snapshot replaces the chat DOM." in content + assert "renderStopStatus(request);" in content + assert "cancellation_requested" not in content + + finally_block = content[content.index("} finally {"):] + assert 'if (request.stopStatus !== "waiting") releaseSessionRequest(request);' in finally_block + + +def test_stop_and_plan_refreshes_preserve_open_node_dialogs() -> None: + streams = _message_stream_js() + runtime = _runtime_js() + disclosures = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "ui" / "disclosureState.js").read_text(encoding="utf-8") + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + + assert "sessionRuntime.markSessionRendered(state.sessionId, owner);" in streams + assert "if (preserveDisclosures) stepExecutionFeed.captureDisclosureState();" in runtime + assert "openState.set(details.dataset.disclosureKey, details.open);" in disclosures + assert "captureDisclosureState()" in graph + assert '? details.open || node.status === "running"' in graph + + +def test_bottom_attachment_and_node_toggle_use_one_scroll_policy() -> None: + rendering = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "chat" / "rendering.js").read_text(encoding="utf-8") + disclosures = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "ui" / "disclosureState.js").read_text(encoding="utf-8") + main = _main_js() + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + + assert "const BOTTOM_ATTACH_THRESHOLD = 80;" in rendering + assert "currentScrollTop < lastScrollTop - 0.5" in rendering + assert "if (preserveUserPosition && userDetached) return;" in rendering + assert "if (event.deltaY < 0) detachBottomFollow();" in rendering + assert "else if (event.deltaY > 0 && isChatNearBottom()) enterBottomFollow();" in rendering + assert "viewportModeVersion !== transaction.viewportModeVersion" in rendering + assert "position.viewportModeVersion !== viewportModeVersion" in rendering + assert "|| !userDetached) return;" in rendering + assert "if (!userScrollActive && isChatNearBottom()) bottomPinned = true;" not in rendering + assert "if (detachBottom && !followBottom) detachBottomFollow();" in rendering + assert "if (followBottom) return { followBottom: true, userScrollIntent, viewportModeVersion };" in rendering + assert "if (snapshot.followBottom)" in rendering + assert "captureScrollPosition?.(details," in disclosures + assert 'block.dataset.readingAnchor = `${key}:block:${index}`;' in disclosures + assert "absolute: true" not in disclosures + assert "const readingPosition = wasBottomPinned ? null : captureScrollPosition();" in rendering + assert "restoreScrollPosition(transaction.readingPosition);" in rendering + assert "absolute = false" not in rendering + assert "function updatePreservingReadingPosition(update)" in rendering + assert "updatePreservingReadingPosition(() => {" in main + assert "this._updatePreservingReadingPosition(() => {" in graph + assert "const shouldStick = isChatBottomPinned();" not in main + assert "const shouldStick = this._isChatBottomPinned();" not in graph + + +def test_all_chat_disclosures_share_the_reading_position_controller() -> None: + main = _main_js() + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + runtime = _runtime_js() + + render_timeline = main[main.index("function renderTimeline("):main.index("function addAgentTimelineMessage(")] + assert 'details.className = "timeline-thought";' in render_timeline + assert 'details.className = "timeline-function-call";' in render_timeline + assert 'details.className = "timeline-function-response";' in render_timeline + assert render_timeline.count("wireTimelineDetails(details,") == 3 + assert "updatePreservingReadingPosition(() => {" in render_timeline + + render_card = graph[graph.index("_createCard(node)"):graph.index("// ---------------------------------------------------------------------------\n// Execution Plan Graph")] + assert 'this._disclosures.wire(details, `step:${node.id}:card`' in render_card + assert 'this._wireNested(node.id, "input"' in render_card + assert 'this._wireNested(node.id, "section:conversation"' in render_card + assert 'this._wireNested(node.id, "section:toolcalls"' in render_card + assert "this._wireNested(node.id, key, this._renderStepConversationEvent(evt))" in render_card + assert "this._wireNested(node.id, key, this._renderStepToolCall(tc))" in render_card + + assert "beginScrollTransaction();" in runtime + assert "endScrollTransaction();" in runtime + assert "endScrollTransaction({ revealBottom: awaitingPlanApproval });" not in runtime + + +def test_image_and_all_timeline_updates_preserve_node_disclosures() -> None: + main = _main_js() + rendering = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "chat" / "rendering.js").read_text(encoding="utf-8") + render_timeline = main[main.index("function renderTimeline("):main.index("function addAgentTimelineMessage(")] + create_image = main[main.index("function createTimelineImage("):main.index("function isExecutorLauncherTool(")] + + assert "disclosures.capture(chatArea);" in render_timeline + assert "container.innerHTML = \"\";" in render_timeline + assert render_timeline.index("disclosures.capture(chatArea);") < render_timeline.index('container.innerHTML = "";') + assert "pendingRestoreSnapshot.userScrollIntent !== snapshot.userScrollIntent" in rendering + assert "[data-reading-anchor]" in rendering + assert 'anchorKeyType: anchorEl?.dataset.readingAnchor ? "reading"' in rendering + assert "function protectAsyncContentLayout(root)" in rendering + assert 'img:not([data-layout-protected])' in rendering + assert "protectAsyncContentLayout(body);" in render_timeline + assert "protectAsyncContentLayout(div);" in render_timeline + assert create_image.count("updatePreservingReadingPosition(() => {") == 2 + assert "prepareAsyncReadingPositionUpdate" not in create_image + + +def test_plain_text_blocks_keep_history_order_and_stable_identity() -> None: + timeline = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "chat" / "timeline.js").read_text(encoding="utf-8") + streams = _message_stream_js() + runtime = _runtime_js() + main = _main_js() + + text_upsert = timeline[timeline.index("export function upsertTimelineText"):timeline.index("function timelineEventKey")] + assert 'const last = timeline[timeline.length - 1];' in text_upsert + assert 'if (last?.type === "text")' in text_upsert + assert 'timelineId: nextTimelineItemId(timeline, "text")' in text_upsert + assert "timeline.splice" not in text_upsert + assert 'upsertTimelineText(timeline, part.text);' in streams + assert 'upsertTimelineText(timeline, part.text);' in runtime + assert 'let accumulatedText = "";' not in streams + assert 'let accumulatedText = "";' not in runtime + assert '${item.timelineId || "text:legacy"}:content' in main def test_step_cancellation_identifies_the_active_session_owner() -> None: diff --git a/tests/test_web_session_access.py b/tests/test_web_session_access.py index 9268908c..0d9cf6de 100644 --- a/tests/test_web_session_access.py +++ b/tests/test_web_session_access.py @@ -358,3 +358,26 @@ def test_local_mode_reads_session_detail_regardless_of_requested_user(monkeypatc assert payload["userId"] == "legacy-display-name" assert payload["state"] == {"answer": 42} assert payload["events"] == [{"event": "persisted"}] + + +def test_execution_graph_endpoint_reads_atomic_graph_snapshot(monkeypatch, tmp_path): + web_main = _load_web_main(monkeypatch) + db_path = tmp_path / "session.db" + _create_session_db(db_path, web_main.APP_NAME) + graph = { + "graph_id": "plan-2", + "nodes": {"step-1": {"label": "Prepare structure", "status": "pending"}}, + "edges": [], + } + with sqlite3.connect(db_path) as conn: + conn.execute( + "UPDATE sessions SET state = ? WHERE app_name = ? AND id = ?", + (json.dumps({"execution_graph": [graph]}), web_main.APP_NAME, "session-1"), + ) + conn.commit() + monkeypatch.setattr(web_main, "SESSION_DB_PATH", db_path) + monkeypatch.setattr(web_main, "_MATCREATOR_MODE", "local") + + response = asyncio.run(web_main.get_execution_graph("session-1")) + + assert json.loads(response.body) == graph diff --git a/web/main.py b/web/main.py index 743c81f2..3c361868 100644 --- a/web/main.py +++ b/web/main.py @@ -9,6 +9,8 @@ --------- GET /api/agent-graph/{session_id} Returns the JSON graph file for the session, or an empty graph if not found. +GET /api/agent-graph/{session_id}/events + Streams graph snapshots whenever an agent node emits a new event. GET /api/workspace/files?path= Serves any file from the workspace root (absolute or relative path). Returns 403 if the path escapes the workspace root. @@ -70,6 +72,8 @@ import users_db # noqa: E402 +from structure_formats import is_vasp_structure_filename # noqa: E402 + from matcreator.workspace import get_session_workdir, get_workspace_root, workspace_skills_dir # noqa: E402 from matcreator.agents.cancellation import ( # noqa: E402 request_cancellation, @@ -79,6 +83,7 @@ request_step_cancellation, ) from matcreator.agents.graph_logger import AgentGraphLogger # noqa: E402 +from matcreator.agents.execution_graph_state import decode_execution_graph # noqa: E402 from matcreator.agents.session_log import build_session_log_export # noqa: E402 from matcreator.skill import ( # noqa: E402 ALL_SKILLS, @@ -97,6 +102,7 @@ from matcreator.control_plane.remote_job_monitor import RemoteJobMonitor # noqa: E402 from matcreator.control_plane.remote_job_service import RemoteJobService # noqa: E402 from matcreator.control_plane.remote_jobs import RemoteJobStore # noqa: E402 +from matcreator.control_plane.providers import CapabilityError # noqa: E402 from matcreator.control_plane.benchmark_client import BenchmarkApiError, BenchmarkClient, sanitize_bank_id # noqa: E402 from matcreator.control_plane.evaluation_manager import EvaluationManager # noqa: E402 from matcreator.control_plane.evaluation_runtime import RuntimeOutcome, RuntimeSpec # noqa: E402 @@ -1623,8 +1629,7 @@ def _load_json_field(raw_value: str | None, fallback): def _ase_read_structure(path: Path): from ase.io import read as ase_read - name = path.name.lower() - if name in {"poscar", "contcar"} or path.suffix.lower() == ".vasp": + if is_vasp_structure_filename(path.name) or path.suffix.lower() == ".vasp": return ase_read(str(path), format="vasp") return ase_read(str(path)) @@ -2668,12 +2673,14 @@ async def pause_session_remote_job( job_id: str, user_id: str = Query(..., description="Current signed-in user"), ) -> JSONResponse: - """Pause one E2B sandbox and notify its linked executor without stopping it.""" + """Pause one remote job (if its provider supports pausing) and notify its linked executor without stopping it.""" job = _get_owned_remote_job(session_id, job_id, user_id) try: - paused = await asyncio.to_thread(_remote_job_service_for_owner(user_id).pause_e2b, job_id) + paused = await asyncio.to_thread(_remote_job_service_for_owner(user_id).pause_job, job_id) except (KeyError, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except CapabilityError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc await asyncio.to_thread( _remote_job_store_for_owner(user_id).record_user_control, job_id, @@ -2688,10 +2695,10 @@ async def terminate_session_remote_job( job_id: str, user_id: str = Query(..., description="Current signed-in user"), ) -> JSONResponse: - """Terminate one E2B sandbox and notify its linked executor without stopping it.""" + """Terminate one remote job and notify its linked executor without stopping it.""" job = _get_owned_remote_job(session_id, job_id, user_id) try: - terminated = await asyncio.to_thread(_remote_job_service_for_owner(user_id).terminate_e2b, job_id) + terminated = await asyncio.to_thread(_remote_job_service_for_owner(user_id).terminate_job, job_id) except (KeyError, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc await asyncio.to_thread( @@ -2708,12 +2715,10 @@ async def refresh_session_remote_job( job_id: str, user_id: str = Query(..., description="Current signed-in user"), ) -> JSONResponse: - """Synchronize a caller-owned active E2B job with its sandbox.""" + """Synchronize a caller-owned active remote job with its provider.""" job = _get_owned_remote_job(session_id, job_id, user_id) - if job["provider"] != "e2b": - raise HTTPException(status_code=409, detail="Remote job is not managed by E2B") try: - refreshed = await asyncio.to_thread(_remote_job_service_for_owner(user_id).reconcile_e2b, job_id) + refreshed = await asyncio.to_thread(_remote_job_service_for_owner(user_id).reconcile_job, job_id) except (KeyError, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return JSONResponse(refreshed) @@ -2796,10 +2801,8 @@ def _load_execution_graph(session_id: str) -> dict: if row is None: continue state = _load_json_field(row["state"], {}) - raw = state.get("execution_graph") - if isinstance(raw, str): - raw = _load_json_field(raw, None) - if not isinstance(raw, dict): + raw = decode_execution_graph(state.get("execution_graph")) + if raw is None: return {"nodes": {}, "edges": []} return raw except sqlite3.Error: @@ -3319,6 +3322,31 @@ async def get_agent_graph(session_id: str) -> JSONResponse: return JSONResponse(data) +@app.get("/api/agent-graph/{session_id}/events") +async def stream_agent_graph(session_id: str, request: Request) -> StreamingResponse: + """Push graph updates so concurrent node output appears without polling.""" + async def stream(): + last_updated_at = object() + while not await request.is_disconnected(): + data = _load_agent_graph_data(session_id) + if not data: + data = {"session_id": session_id, "nodes": {}, "edges": [], "updated_at": None} + updated_at = data.get("updated_at") + if updated_at != last_updated_at: + yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + last_updated_at = updated_at + # The logger writes synchronously for every model/tool event. A + # short server-side wait keeps the browser connection quiet while + # making independently running nodes feel genuinely concurrent. + await asyncio.sleep(0.2) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @app.post("/api/workspace/cli") async def run_workspace_cli(body: WorkspaceCliBody) -> JSONResponse: command = body.command.strip() @@ -3669,7 +3697,7 @@ async def list_modeling_structure_files( for path in sorted(root.rglob("*")): if not path.is_file(): continue - if path.suffix.lower() not in structure_suffixes and path.name.lower() not in {"poscar", "contcar"}: + if path.suffix.lower() not in structure_suffixes and not is_vasp_structure_filename(path.name): continue files.append({ "name": path.name, @@ -3769,7 +3797,7 @@ async def cancel_session_execution( paused_jobs = [] if user_id: paused_jobs = await asyncio.to_thread( - _remote_job_service_for_owner(user_id).pause_active_session_e2b_jobs, + _remote_job_service_for_owner(user_id).pause_active_session_jobs, owner_id=user_id, session_id=session_id, ) diff --git a/web/structure_formats.py b/web/structure_formats.py new file mode 100644 index 00000000..ff4124c6 --- /dev/null +++ b/web/structure_formats.py @@ -0,0 +1,16 @@ +"""Filename-based structure format detection shared by web endpoints.""" + +from __future__ import annotations + +import re + + +def is_vasp_structure_filename(filename: str) -> bool: + """Return whether *filename* is a conventional POSCAR/CONTCAR variant. + + VASP workflows commonly preserve the original POSCAR/CONTCAR prefix when + creating variants, for example ``POSCAR_water_layer2`` or + ``POSCAR-2_water6``. ASE cannot infer the VASP format from those names, + so recognize a prefix followed by the usual filename delimiters. + """ + return bool(re.fullmatch(r"(?:poscar|contcar)(?:[_.-].*)?", filename, re.IGNORECASE)) diff --git a/web/vite-frontend/index.html b/web/vite-frontend/index.html index 6f3dd2ba..b20be8b0 100644 --- a/web/vite-frontend/index.html +++ b/web/vite-frontend/index.html @@ -184,6 +184,10 @@

Agent Graph

+
diff --git a/web/vite-frontend/package-lock.json b/web/vite-frontend/package-lock.json index f7e6c9b9..1807849a 100644 --- a/web/vite-frontend/package-lock.json +++ b/web/vite-frontend/package-lock.json @@ -43,13 +43,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -99,12 +99,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -137,17 +137,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -155,9 +155,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1143,6 +1143,15 @@ } } }, + "node_modules/@mui/material/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@mui/private-theming": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", @@ -1243,6 +1252,15 @@ } } }, + "node_modules/@mui/system/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@mui/types": { "version": "7.2.24", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", @@ -1287,6 +1305,35 @@ } } }, + "node_modules/@mui/utils/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1308,9 +1355,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -1322,9 +1369,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -1336,9 +1383,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -1350,9 +1397,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -1364,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -1378,9 +1425,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -1392,13 +1439,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1406,13 +1456,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1420,13 +1473,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1434,13 +1490,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1448,13 +1507,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1462,13 +1524,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1476,13 +1541,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1490,13 +1558,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1504,13 +1575,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1518,13 +1592,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1532,13 +1609,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1546,13 +1626,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1560,13 +1643,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1574,9 +1660,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -1588,9 +1674,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -1602,9 +1688,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -1616,9 +1702,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -1630,9 +1716,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -1644,9 +1730,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -1658,9 +1744,9 @@ ] }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz", + "integrity": "sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1707,9 +1793,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/hammerjs": { @@ -1741,9 +1827,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", "peer": true, "dependencies": { @@ -1793,9 +1879,9 @@ ] }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1814,9 +1900,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "license": "MIT", "engines": { "node": ">=12" @@ -1922,15 +2008,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/call-bind": { @@ -2113,9 +2199,9 @@ } }, "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2662,9 +2748,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", "license": "MIT" }, "node_modules/dom-helpers": { @@ -2678,9 +2764,9 @@ } }, "node_modules/dpdm": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.2.0.tgz", - "integrity": "sha512-Vq862fZ9UE66rlr2VcMhU8ZstTH3ItqmniLSCtAeg6T2AYeB2oD3Z6lGjiFDjyUvxLbfLyBBNWagCLMehpmo5g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.3.0.tgz", + "integrity": "sha512-2ZrP5B3MHHo7mXWgNxntU5DhJIvXNP4hcMBdCJsuxEWenKMdf4h6XouuKMf3Sj2SAUNBy/ajcWAVIyiOZly6rw==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -2827,9 +2913,9 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", - "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", + "integrity": "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2897,9 +2983,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -3131,9 +3217,9 @@ } }, "node_modules/indigo-ketcher": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/indigo-ketcher/-/indigo-ketcher-1.45.0.tgz", - "integrity": "sha512-ilq2cZtB237g3lVC5ry1evk6TMmNxfLYevLxPO07aelTeKPCf9Tu1wnijiMsaOiGytAFJ2mU11wkroe1iHYfSg==", + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/indigo-ketcher/-/indigo-ketcher-1.45.1.tgz", + "integrity": "sha512-A2+v9SNRuJWeMyLc+IhU/1o/G1CvQ/DWVWonZ+owwuZyhwVb8nfaYsg2Gc0UjB1hNEpo832wd0OgC/8kbDWMjw==", "license": "Apache-2.0" }, "node_modules/inherits": { @@ -3409,9 +3495,9 @@ } }, "node_modules/ketcher-core": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ketcher-core/-/ketcher-core-3.17.0.tgz", - "integrity": "sha512-xONVbx3xfjHLwACYh8JaZudiOYIVXhVxf3pSc5pZ3Die7yN9TTRAFMcK5Hxu3ziNrn4OdhSyUZGnS8sJKAimVA==", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/ketcher-core/-/ketcher-core-3.17.2.tgz", + "integrity": "sha512-jCh/oqlQEdTJ3++SyFGQ/a/IKXVQ4S29lOqOTvlItvlduUqpaYtnXZgYR74baGGuVgR5A61iqxCJpHT3eOfHVA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.10", @@ -3430,9 +3516,9 @@ } }, "node_modules/ketcher-react": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ketcher-react/-/ketcher-react-3.17.0.tgz", - "integrity": "sha512-cGcJZvnzRyUJk+6EjROIkp/mIfbDmmY30ghKsox3P+jd1aWVatXGeyUceUC8maZ5TpLwkGFGLwCR07l34rTdLA==", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/ketcher-react/-/ketcher-react-3.17.2.tgz", + "integrity": "sha512-sU/LjxAcxBaO1r7SaAL3NNd2gvdCog5v+4I8TNAmAy47f+TOwt6zx8VDvU0s7ajJ6dpTpIu8m2tR1hSKIh13yw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.10", @@ -3483,36 +3569,14 @@ "react-dom": "^18.2.0 || ^19.0.0" } }, - "node_modules/ketcher-react/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ketcher-react/node_modules/miew-react": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/miew-react/-/miew-react-0.11.0.tgz", - "integrity": "sha512-IdJYzrbkpotS4Hx0JYg7KyOcpwyTdGRS1hnc504Y//Xvdl24Ft6uahuf9mbCEI2PNVPDARLc+hqzMTbpDWD88A==", - "license": "MIT", - "dependencies": { - "miew": "^0.11.0" - }, - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "node_modules/ketcher-standalone": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ketcher-standalone/-/ketcher-standalone-3.17.0.tgz", - "integrity": "sha512-MBpMC90UintTKuRrCsaQYs1WwDlX2tIm6+Cr6gohvWay0x/o5YFP0o5nZaYZNqQAvyI+TxHSwBqpPm+SIFsrqw==", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/ketcher-standalone/-/ketcher-standalone-3.17.2.tgz", + "integrity": "sha512-n5o+p1hyjVh2rHTXSXUsa1rehUp3ZrD3mds9TnKmechVdPe44TOtcGgKI7+ZuXGukTd76TE/CiMlbCjpPtlpfA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.10", - "indigo-ketcher": "1.45.0", + "indigo-ketcher": "1.45.1", "ketcher-core": "*" }, "engines": { @@ -3914,6 +3978,19 @@ "three": "0.153.0" } }, + "node_modules/miew-react": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/miew-react/-/miew-react-0.11.0.tgz", + "integrity": "sha512-IdJYzrbkpotS4Hx0JYg7KyOcpwyTdGRS1hnc504Y//Xvdl24Ft6uahuf9mbCEI2PNVPDARLc+hqzMTbpDWD88A==", + "license": "MIT", + "dependencies": { + "miew": "^0.11.0" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, "node_modules/miew/node_modules/three": { "version": "0.153.0", "resolved": "https://registry.npmjs.org/three/-/three-0.153.0.tgz", @@ -3933,12 +4010,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -3963,9 +4040,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -4179,9 +4256,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", - "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -4199,7 +4276,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4295,15 +4372,6 @@ "react-dom": ">=16" } }, - "node_modules/react-contexify/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react-device-detect": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz", @@ -4372,9 +4440,9 @@ } }, "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "license": "MIT" }, "node_modules/react-lifecycles-compat": { @@ -4440,15 +4508,6 @@ "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-virtualized/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4580,13 +4639,13 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -4596,31 +4655,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -4778,9 +4838,9 @@ } }, "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "version": "5.56.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", + "integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -4804,6 +4864,15 @@ "node": ">=18" } }, + "node_modules/svelte/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/svgpath": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", @@ -5182,15 +5251,15 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" }, @@ -5207,27 +5276,10 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yjs": { - "version": "13.6.31", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", - "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "version": "13.6.32", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz", + "integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==", "license": "MIT", "peer": true, "dependencies": { @@ -5243,9 +5295,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "license": "MIT", "engines": { "node": ">=18" diff --git a/web/vite-frontend/src/components/OrbitalAgentIndicator.css b/web/vite-frontend/src/components/OrbitalAgentIndicator.css new file mode 100644 index 00000000..ef26b27b --- /dev/null +++ b/web/vite-frontend/src/components/OrbitalAgentIndicator.css @@ -0,0 +1,62 @@ +.orbital-agent-indicator { + display: block; + overflow: visible; +} + +.orbital-agent-indicator__motion { + transform-box: fill-box; + transform-origin: 50% 50%; + animation: orbital-breathe 5.6s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} + +.orbital-agent-indicator__glow { + animation: orbital-glow 3.8s ease-in-out infinite; +} + +.orbital-agent-indicator--searching .orbital-agent-indicator__motion { + animation: orbital-search 3.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} + +.orbital-agent-indicator--computing .orbital-agent-indicator__motion { + animation-duration: 2.8s; +} + +.orbital-agent-indicator--done .orbital-agent-indicator__motion { + animation: orbital-done 1.4s cubic-bezier(0.22, 1, 0.36, 1) 1 both; +} + +.orbital-agent-indicator--done .orbital-agent-indicator__glow { + animation: orbital-done-glow 1.4s ease-out 1 both; +} + +@keyframes orbital-breathe { + 0%, 100% { opacity: 0.78; transform: scale(0.94); } + 50% { opacity: 1; transform: scale(1.04); } +} + +@keyframes orbital-glow { + 0%, 100% { opacity: 0.38; } + 50% { opacity: 0.9; } +} + +@keyframes orbital-search { + 0%, 100% { opacity: 0.8; transform: rotate(-5deg) scale(0.95); } + 50% { opacity: 1; transform: rotate(5deg) scale(1.05); } +} + +@keyframes orbital-done { + 0%, 100% { opacity: 0.82; transform: scale(1); } + 35% { opacity: 1; transform: scale(1.14); } +} + +@keyframes orbital-done-glow { + 0%, 100% { opacity: 0.38; } + 35% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .orbital-agent-indicator__motion, + .orbital-agent-indicator__glow { + animation: none; + } +} diff --git a/web/vite-frontend/src/components/OrbitalAgentIndicator.jsx b/web/vite-frontend/src/components/OrbitalAgentIndicator.jsx new file mode 100644 index 00000000..8af587fa --- /dev/null +++ b/web/vite-frontend/src/components/OrbitalAgentIndicator.jsx @@ -0,0 +1,183 @@ +import React, { useEffect, useId, useRef, useState } from "react"; +import "./OrbitalAgentIndicator.css"; + +// Every orbital uses eight cubic Bezier segments in the same order. Keeping the +// path commands compatible lets the browser interpolate `d` smoothly in SMIL, +// without a JavaScript animation loop or a path-morphing dependency. +const ORBITALS = { + // A rounded, isotropic s orbital: no nodal waist or directional lobe. + s: "M 50 10 C 61 10 71 16 78 25 C 85 34 88 42 88 50 C 88 58 85 66 78 75 C 71 84 61 90 50 90 C 39 90 29 84 22 75 C 15 66 12 58 12 50 C 12 42 15 34 22 25 C 29 16 39 10 50 10 Z", + // px: an exact two-lobed figure-eight. The first four Beziers trace the + // right lobe and the next four trace its horizontal mirror; both meet only + // at the central node (50, 50), making the nodal waist unmistakable. + p: "M 50 50 C 50 36 57 20 70 20 C 83 20 90 34 90 50 C 90 66 83 80 70 80 C 57 80 50 64 50 50 C 50 64 43 80 30 80 C 17 80 10 66 10 50 C 10 34 17 20 30 20 C 43 20 50 36 50 50 Z", + // Four broad, equal d-orbital leaves. Each pair of curves leaves and returns + // to the central node, so all four lobes remain full at small icon sizes. + d: "M 50 50 C 35 42 20 22 50 8 C 80 22 65 42 50 50 C 58 35 78 20 92 50 C 78 80 58 65 50 50 C 65 58 80 78 50 92 C 20 78 35 58 50 50 C 42 65 22 80 8 50 C 22 20 42 35 50 50 Z", +}; + +const ACTIVE_STATES = new Set(["thinking", "searching", "computing"]); +const REST_MIN_MS = 2400; +const REST_MAX_MS = 4000; +const TRANSITION_MS = 560; + +// The paths above intentionally share one `M`, eight `C`, and `Z` commands. +// We therefore interpolate their corresponding Bezier coordinates directly, +// producing a genuine intermediate SVG geometry on every transition frame. +function pathNumbers(path) { + return path.match(/-?\d*\.?\d+/g).map(Number); +} + +const PATH_POINTS = Object.fromEntries( + Object.entries(ORBITALS).map(([name, path]) => [name, pathNumbers(path)]), +); + +function serializePath(points) { + let result = `M ${points[0].toFixed(2)} ${points[1].toFixed(2)}`; + for (let index = 2; index < points.length; index += 6) { + result += ` C ${points.slice(index, index + 6).map((value) => value.toFixed(2)).join(" ")}`; + } + return `${result} Z`; +} + +function interpolatePath(from, to, progress) { + const fromPoints = PATH_POINTS[from]; + const toPoints = PATH_POINTS[to]; + return serializePath(fromPoints.map((value, index) => value + (toPoints[index] - value) * progress)); +} + +function excitationEase(progress) { + // Smooth acceleration/deceleration plus a restrained overshoot before settle. + const shifted = progress - 1; + return 1 + 2.1 * shifted ** 3 + 1.1 * shifted ** 2; +} + +function chooseNextOrbital(current) { + const choices = Object.keys(ORBITALS).filter((orbital) => orbital !== current); + return choices[Math.floor(Math.random() * choices.length)]; +} + +const THEME_COLORS = { + cool: "#7dd3fc", + violet: "#c4b5fd", + emerald: "#6ee7b7", +}; + +/** + * A compact, SVG-only agent-status animation inspired by atomic orbitals. + * + * Active states rest on an orbital and periodically select a different s/p/d + * target. The only per-frame work is a 560 ms coordinate interpolation during + * an excitation; resting phases remain entirely CSS-driven. `color` accepts + * any CSS color; `theme` provides compact default palettes. + */ +export function OrbitalAgentIndicator({ + state = "idle", + size = 32, + color, + theme = "cool", + className = "", + title = "MatCreator status", +}) { + const id = useId().replaceAll(":", ""); + const safeState = ["idle", "thinking", "searching", "computing", "done"].includes(state) ? state : "idle"; + // Always begin at the s orbital. The active cycle subsequently chooses among + // all three states rather than imposing a fixed mode-specific sequence. + const [orbital, setOrbital] = useState("s"); + const [nextOrbital, setNextOrbital] = useState(null); + const [displayPath, setDisplayPath] = useState(ORBITALS.s); + const isInitialDwell = useRef(true); + const isTransitioning = nextOrbital !== null; + + // Rest on an orbital, then choose a different s/p/d target at random. + useEffect(() => { + if (!ACTIVE_STATES.has(safeState) || isTransitioning) return undefined; + // Ensure the first visible active state is a clearly recognizable s cloud. + const delay = isInitialDwell.current + ? REST_MAX_MS + : REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS); + const timer = window.setTimeout(() => { + isInitialDwell.current = false; + setNextOrbital(chooseNextOrbital(orbital)); + }, delay); + return () => window.clearTimeout(timer); + }, [safeState, orbital, nextOrbital, isTransitioning]); + + useEffect(() => { + if (!nextOrbital) return undefined; + const start = performance.now(); + let frameId; + const animate = (now) => { + const progress = Math.min((now - start) / TRANSITION_MS, 1); + setDisplayPath(interpolatePath(orbital, nextOrbital, excitationEase(progress))); + if (progress < 1) frameId = requestAnimationFrame(animate); + else { + setDisplayPath(ORBITALS[nextOrbital]); + setOrbital(nextOrbital); + setNextOrbital(null); + } + }; + frameId = requestAnimationFrame(animate); + return () => cancelAnimationFrame(frameId); + }, [orbital, nextOrbital]); + + const orbitalColor = color || THEME_COLORS[theme] || THEME_COLORS.cool; + const gradientId = `orbital-gradient-${id}`; + const glowId = `orbital-glow-${id}`; + + return ( + + {title} + + + + + + + + + + + + + + + {isTransitioning && ( + + )} + + + + + + + + + + ); +} + +export default OrbitalAgentIndicator; diff --git a/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js new file mode 100644 index 00000000..5531c2a8 --- /dev/null +++ b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js @@ -0,0 +1,18 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import OrbitalAgentIndicator from "./OrbitalAgentIndicator.jsx"; + +// The main chat is vanilla JS. This narrow bridge mounts the reusable React +// indicator without making the rest of the composer depend on React. +export function mountOrbitalAgentIndicator(target) { + if (!target) return null; + const root = createRoot(target); + const render = (state = "idle") => root.render(React.createElement(OrbitalAgentIndicator, { + state, + size: 18, + color: "var(--accent)", + title: `MatCreator is ${state}`, + })); + render(); + return { render, unmount: () => root.unmount() }; +} diff --git a/web/vite-frontend/src/features/chat/messageStream.js b/web/vite-frontend/src/features/chat/messageStream.js index 8fbd55b7..38f8cae1 100644 --- a/web/vite-frontend/src/features/chat/messageStream.js +++ b/web/vite-frontend/src/features/chat/messageStream.js @@ -1,6 +1,4 @@ import { - compactRepeatedPrefixSnapshots, - mergeReplayedText, upsertTimelineEvent, upsertTimelineText, upsertTimelineThought, @@ -12,28 +10,59 @@ export function createMessageStreamController(deps) { state, appName, chatArea, textInput, activeSessionRequest, sessionRequestKey, activeSessionBackendUserId, canWriteActiveSession, showLoginModal, createSession, addMessage, addAgentTimelineMessage, addPlanApprovalActions, renderTimeline, messageWithUploadNames, messageWithUploadContext, clearCurrentUploads, - autoResizeTextInput, stepExecutionFeed, agentGraph, planGraph, updateSendButtonState, + autoResizeTextInput, stepExecutionFeed, agentGraph, planGraph, updateSendButtonState, updateAgentRunningStatus, releaseSessionRequest, managedRunEventsUrl, shouldRefreshPlanGraphForTool, generateSessionSummary, refreshSessionFiles, sessionRuntime, } = deps; - function pollCancellationConfirmed(sessionId, owner, attempts = 0) { - if (attempts >= 20) { - addMessage("agent", "⚠️ Stop requested but execution may still be running in the background."); + function renderStopStatus(request) { + if (!request.stopStatus || sessionRequestKey(request.sessionId, request.owner) !== sessionRequestKey()) return; + const content = request.stopStatus === "stopped" ? "✓ Execution stopped." : "Still executing…"; + if (!request.stopStatusMessage?.isConnected) { + request.stopStatusMessage = addMessage("agent", content); return; } - setTimeout(async () => { - try { - const query = new URLSearchParams({ user_id: owner || state.userId }); - const response = await fetch(`/api/sessions/${sessionId}/cancel?${query}`); - const result = await response.json(); - if (!result.cancellation_requested) { - addMessage("agent", "✓ Execution stopped."); - return; + const inner = request.stopStatusMessage.querySelector(".markdown-content"); + if (inner) inner.textContent = content; + } + + async function pollCancellationConfirmed(request, attempts = 0) { + await new Promise((resolve) => setTimeout(resolve, attempts ? Math.min(1000 + attempts * 100, 3000) : 0)); + try { + let run = null; + if (request.runId) { + const response = await fetch(`/api/runs/${encodeURIComponent(request.runId)}`); + if (response.ok) run = await response.json(); + } else { + const query = new URLSearchParams({ user_id: request.owner || state.userId, session_id: request.sessionId }); + const response = await fetch(`/api/runs/active?${query}`); + if (response.ok) run = (await response.json()).run; + } + // A stop can be clicked while POST /api/runs is still returning. Give + // active-run discovery a short grace period before treating "not found" + // as terminal, so a just-created run cannot slip past the stop lock. + if (!run && !request.runId && attempts < 3) { + request.stopStatus = "waiting"; + renderStopStatus(request); + void pollCancellationConfirmed(request, attempts + 1); + return; + } + if (!run || ["completed", "failed", "cancelled"].includes(run.status)) { + request.stopStatus = "stopped"; + releaseSessionRequest(request); + if (sessionRequestKey(request.sessionId, request.owner) === sessionRequestKey()) { + await sessionRuntime.loadSession(request.sessionId, request.owner); } - } catch (_) { /* Ignore transient network errors. */ } - pollCancellationConfirmed(sessionId, owner, attempts + 1); - }, 2000); + renderStopStatus(request); + return; + } + request.stopStatus = "waiting"; + renderStopStatus(request); + } catch (_) { + request.stopStatus = "waiting"; + renderStopStatus(request); + } + void pollCancellationConfirmed(request, attempts + 1); } function stop() { @@ -42,7 +71,10 @@ export function createMessageStreamController(deps) { sessionRuntime.suppressPlanApproval(request.sessionId); const query = new URLSearchParams({ user_id: request.owner || state.userId }); fetch(`/api/sessions/${request.sessionId}/cancel?${query}`, { method: "POST" }).catch(() => {}); + request.stopStatus = "waiting"; + renderStopStatus(request); request.controller.abort(); + updateAgentRunningStatus("working"); pollCancellationConfirmed(request.sessionId, request.owner); } @@ -86,14 +118,18 @@ export function createMessageStreamController(deps) { agentGraph.startPolling(state.sessionId); planGraph.startPolling(state.sessionId, { autoOpenOnNewGraph: true, autoOpenBaselineKey: previousPlanGraphKey }); const owner = state.activeSessionUserId || state.userId; + // The optimistic user message and live assistant shell already represent + // this session. Mark it before the first persisted snapshot arrives so + // stop/plan-completion refreshes preserve the visible disclosure state. + sessionRuntime.markSessionRendered(state.sessionId, owner); const request = { key: sessionRequestKey(state.sessionId, owner), sessionId: state.sessionId, owner, backendUserId: activeSessionBackendUserId(), controller: new AbortController(), lastSequence: 0, runId: null, }; state.activeRequests.set(request.key, request); + updateAgentRunningStatus("thinking"); updateSendButtonState(); - let accumulatedText = ""; let lineBuffer = ""; let summaryTriggered = false; let validatedPlanThisTurn = false; @@ -105,17 +141,25 @@ export function createMessageStreamController(deps) { if (data === "[DONE]") return; try { for (const part of JSON.parse(data)?.content?.parts || []) { - if (part.thought) upsertTimelineThought(timeline, part.text || ""); - else if (part.functionCall) upsertTimelineEvent(timeline, { type: "function_call", id: part.functionCall.id, name: part.functionCall.name || "Unknown", args: part.functionCall.args || {} }); + if (part.thought) { + updateAgentRunningStatus("thinking"); + upsertTimelineThought(timeline, part.text || ""); + } else if (part.functionCall) { + const name = part.functionCall.name || "Unknown"; + updateAgentRunningStatus(phaseForTool(name)); + upsertTimelineEvent(timeline, { type: "function_call", id: part.functionCall.id, name, args: part.functionCall.args || {} }); + } else if (part.functionResponse) { const response = part.functionResponse; upsertTimelineEvent(timeline, { type: "function_response", id: response.id, name: response.name || "Unknown", response: response.response || {} }); + updateAgentRunningStatus(phaseForTool(response.name)); if (shouldRefreshPlanGraphForTool(response.name)) planGraph.refresh(request.sessionId); if ((response.name === "validate_graph" || response.name === "validate_plan") && response.response?.status === "ok") validatedPlanThisTurn = true; if ((response.name === "confirm_plan_and_start_execution" || response.name === "resume_execution") && response.response?.status === "ok") executionApprovedThisTurn = true; } else if (part.text) { + updateAgentRunningStatus("thinking"); accumulatedText = mergeReplayedText(accumulatedText, part.text); upsertTimelineText(timeline, compactRepeatedPrefixSnapshots(accumulatedText)); if (!summaryTriggered && !state.summaryGeneratedFor.has(request.sessionId) && !state.sessionSummaries[request.sessionId]) { @@ -188,9 +232,12 @@ export function createMessageStreamController(deps) { } if (lineBuffer.trim().startsWith("data: ")) handleAdkData(lineBuffer.trim().slice(6)); } catch (error) { - addMessage("agent", error?.name === "AbortError" ? "Stopping execution…" : `Backend error: ${error}`, undefined, liveTurn); + if (error?.name !== "AbortError") addMessage("agent", `Backend error: ${error}`, undefined, liveTurn); } finally { - releaseSessionRequest(request); + // A cancelled browser subscription can finish before the managed run + // does. Keep the composer locked until cancellation polling observes a + // terminal run, otherwise a new send would clear the cancellation flag. + if (request.stopStatus !== "waiting") releaseSessionRequest(request); await agentGraph._poll(request.sessionId); agentGraph.stopPolling(); await planGraph._poll(request.sessionId); @@ -198,6 +245,10 @@ export function createMessageStreamController(deps) { await refreshSessionFiles(request.sessionId, request.owner); stepExecutionFeed.finishLiveTurn(); await reloadSessionSnapshot(); + // A session snapshot replaces the chat DOM. Restore the stop indicator + // from the request state so cancellation feedback is never lost during + // the final refresh. + renderStopStatus(request); // Do not depend on session DB timing for the prompt. The live ADK // response is authoritative; the persisted snapshot remains a fallback // for page refreshes and reconnects. @@ -212,5 +263,14 @@ export function createMessageStreamController(deps) { } } + function phaseForTool(name = "") { + const tool = String(name).toLowerCase(); + if (tool.includes("search") || tool.includes("retrieve") || tool.includes("lookup")) return "searching"; + if (tool.includes("plan") || tool.includes("graph") || tool.includes("decompos")) return "planning"; + if (tool.includes("run_") || tool.includes("execute") || tool.includes("submit") || tool.includes("resume")) return "executing"; + if (tool.includes("calc") || tool.includes("simulate") || tool.includes("compute")) return "computing"; + return "working"; + } + return { send, stop }; } diff --git a/web/vite-frontend/src/features/chat/rendering.js b/web/vite-frontend/src/features/chat/rendering.js index 1b6d9440..1ac8676b 100644 --- a/web/vite-frontend/src/features/chat/rendering.js +++ b/web/vite-frontend/src/features/chat/rendering.js @@ -8,18 +8,147 @@ const AGENT_AVATAR_SVG = ` `; +// Native scrolling commonly stops a few fractional pixels short of the +// mathematical maximum (and trackpads can finish between scroll events). +// Treat the bottom composer-sized reading zone as attached to the bottom. +const BOTTOM_ATTACH_THRESHOLD = 80; -export function createChatRenderer({ chatArea }) { +export function createChatRenderer({ chatArea, bottomOverlay = null }) { let asciiWidth = 0; let cjkWidth = 0; let userScrollIntent = 0; let pendingScrollFrame = null; + let pendingRestoreFrame = null; + let pendingRestoreSnapshot = null; + let pendingBottomRequest = null; + let bottomReserve = 0; + let observedBottomDialog = null; + // User intent is the source of truth. Geometry can change underneath the + // viewport while content streams, so "near bottom" must not double as the + // follow-mode state. + let userDetached = false; + let viewportModeVersion = 0; + let pointerScrollActive = false; + let lastUserScrollIntentAt = 0; + let lastScrollTop = chatArea.scrollTop; + let scrollTransaction = null; - ["pointerdown", "touchstart", "wheel"].forEach((eventName) => { - chatArea.addEventListener(eventName, () => { + function cancelScheduledPlacement() { + if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingScrollFrame = null; + pendingRestoreFrame = null; + pendingBottomRequest = null; + pendingRestoreSnapshot = null; + } + + function enterBottomFollow() { + if (!userDetached) return; + userDetached = false; + viewportModeVersion += 1; + // A detached-mode restore may have been queued by a content update after + // the user's wheel/pointer event but before its resulting scroll event. + // Reaching the bottom supersedes that anchor unconditionally. + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingRestoreFrame = null; + pendingRestoreSnapshot = null; + } + + const bottomDialogObserver = new ResizeObserver(() => { + // Dialog growth (streaming text, details, images) changes where the + // bottom is, but must never change the size of the bottom spacer. + if (isChatBottomPinned()) scrollToBottom({ preserveUserPosition: true }); + }); + + function getBottomDialog() { + return [...chatArea.children].reverse().find((element) => ( + element.matches?.(".message:not(.is-pending)") && !element.classList.contains("hidden") + )) || null; + } + + function observeBottomDialog() { + const dialog = getBottomDialog(); + if (dialog === observedBottomDialog) return dialog; + if (observedBottomDialog) bottomDialogObserver.unobserve(observedBottomDialog); + observedBottomDialog = dialog; + if (observedBottomDialog) bottomDialogObserver.observe(observedBottomDialog); + return dialog; + } + + function syncBottomReserve({ followBottom = true } = {}) { + const overlayHeight = bottomOverlay?.getBoundingClientRect().height || 0; + observeBottomDialog(); + // The input area is absolutely positioned 16px above the panel bottom. + // The message's own bottom margin provides the visual gap. + // Dialog height is deliberately excluded: adding it creates a large + // elastic blank area and destabilizes anchors while content streams. + const reserve = Math.ceil((overlayHeight || 98) + 16); + if (reserve === bottomReserve) return; + const shouldStick = followBottom && isChatBottomPinned(); + const readingPosition = shouldStick ? null : captureScrollPosition(); + bottomReserve = reserve; + chatArea.style.setProperty("--chat-bottom-reserve", `${reserve}px`); + if (shouldStick) scrollToBottom({ preserveUserPosition: true }); + else restoreScrollPosition(readingPosition); + } + if (bottomOverlay) { + new ResizeObserver(syncBottomReserve).observe(bottomOverlay); + syncBottomReserve(); + } + new MutationObserver(() => { + observeBottomDialog(); + syncBottomReserve(); + }).observe(chatArea, { childList: true, attributes: true, attributeFilter: ["class"], subtree: true }); + + ["pointerdown", "touchstart", "wheel", "keydown"].forEach((eventName) => { + chatArea.addEventListener(eventName, (event) => { userScrollIntent += 1; - }, { passive: true }); + // Any real interaction owns the viewport. It invalidates every queued + // write captured before this gesture; a disclosure may immediately + // capture a fresh snapshot later in the same event dispatch. + cancelScheduledPlacement(); + // Pointer presses inside a disclosure are content interactions, not + // scrollbar drags. Treat only a press targeting the scroll container + // itself as a possible scrollbar gesture; otherwise our own anchor + // restore could accidentally re-enable bottom follow before pointerup. + if (eventName === "pointerdown") { + pointerScrollActive = event.target === chatArea; + } + if (eventName === "touchstart") { + lastUserScrollIntentAt = performance.now(); + } + if (eventName === "wheel") { + lastUserScrollIntentAt = performance.now(); + if (event.deltaY < 0) detachBottomFollow(); + else if (event.deltaY > 0 && isChatNearBottom()) enterBottomFollow(); + } + if (eventName === "keydown" && ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { + lastUserScrollIntentAt = performance.now(); + if (["ArrowUp", "PageUp", "Home"].includes(event.key) || (event.key === " " && event.shiftKey)) { + detachBottomFollow(); + } else if (event.key === "End" || isChatNearBottom()) { + enterBottomFollow(); + } + } + }, { passive: true, capture: true }); }); + window.addEventListener("pointerup", () => { pointerScrollActive = false; }, { passive: true }); + window.addEventListener("pointercancel", () => { pointerScrollActive = false; }, { passive: true }); + chatArea.addEventListener("scroll", () => { + // Layout growth and programmatic placement also emit `scroll`. Only an + // explicit, recent user gesture is allowed to attach/detach bottom mode. + const currentScrollTop = chatArea.scrollTop; + if (pointerScrollActive || performance.now() - lastUserScrollIntentAt < 1200) { + // Upward intent always detaches, even while still inside the generous + // bottom zone; downward/neutral motion attaches upon entering it. This + // avoids trapping small wheel steps while retaining reliable attach for + // scrollbar dragging, momentum and fractional scroll positions. + if (currentScrollTop < lastScrollTop - 0.5) detachBottomFollow(); + else if (isChatNearBottom()) enterBottomFollow(); + else userDetached = true; + } + lastScrollTop = currentScrollTop; + }, { passive: true }); function renderMarkdown(text) { if (!text) return ""; @@ -29,7 +158,11 @@ export function createChatRenderer({ chatArea }) { return BOX_RE.test(decoded) ? `
${decoded}
` : match; }; html = html.replace(/
([\s\S]*?)<\/code><\/pre>/gi, wrapAsciiArt);
-    return html.replace(/

([\s\S]*?)<\/p>/gi, wrapAsciiArt); + html = html.replace(/

([\s\S]*?)<\/p>/gi, wrapAsciiArt); + return html.replace( + /([\s\S]*?)<\/table>/gi, + '
$1

', + ); } function unescapeText(text) { @@ -90,10 +223,20 @@ export function createChatRenderer({ chatArea }) { pre.className = "json-block"; pre.dataset.raw = unescapeText(content).replace(/^\{\s*/, "").replace(/\s*\}$/, ""); applyWrapMarkers(pre); - new ResizeObserver(() => applyWrapMarkers(pre)).observe(pre); + new ResizeObserver(() => updatePreservingReadingPosition(() => applyWrapMarkers(pre))).observe(pre); return pre; } + function markReadingAnchors(root, prefix) { + if (!root || !prefix) return; + const blocks = root.matches?.("p, pre, blockquote, li, table, h1, h2, h3, h4, h5, h6") + ? [root] + : [...root.querySelectorAll("p, pre, blockquote, li, table, h1, h2, h3, h4, h5, h6")]; + blocks.forEach((block, index) => { + block.dataset.readingAnchor = `${prefix}:block:${index}`; + }); + } + function getUserAvatar() { return localStorage.getItem("user-avatar-url") || null; } function applyUserAvatarToEl(element) { const url = getUserAvatar(); @@ -116,44 +259,222 @@ export function createChatRenderer({ chatArea }) { return element; } function scrollToBottom({ preserveUserPosition = false } = {}) { - const userIntentAtRequest = userScrollIntent; - if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + // Calling this function is the single transition into bottom-follow mode. + // Activate synchronously so updates arriving before the next animation + // frame also follow the bottom. A subsequent user scroll/disclosure + // interaction atomically cancels the queued placement. + // Passive render/resize requests may continue following an attached + // viewport, but they must never reattach a reader who has scrolled away. + if (preserveUserPosition && userDetached) return; + enterBottomFollow(); + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingRestoreFrame = null; + pendingRestoreSnapshot = null; + if (!pendingBottomRequest) { + pendingBottomRequest = { preserveUserPosition, userIntent: userScrollIntent }; + } else if (!preserveUserPosition) { + // An explicit placement (for example, the user's own message) wins over + // passive streaming requests that should yield to user scroll input. + pendingBottomRequest.preserveUserPosition = false; + pendingBottomRequest.userIntent = userScrollIntent; + } + if (scrollTransaction) { + scrollTransaction.bottomRequested = true; + scrollTransaction.preserveUserPosition &&= preserveUserPosition; + return; + } + if (pendingScrollFrame !== null) return; - //
blocks finish contributing their full height after layout. - // Waiting two frames ensures the bottom target includes every collapsed - // label and its surrounding layout. + // MutationObserver, ResizeObserver and the renderer can all request a + // placement for the same DOM update. Coalesce them into one layout-frame + // write so no intermediate scroll position is painted. pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = null; - if (preserveUserPosition && userScrollIntent !== userIntentAtRequest) return; - chatArea.scrollTop = chatArea.scrollHeight; - }); + pendingScrollFrame = null; + const request = pendingBottomRequest; + pendingBottomRequest = null; + if (!request) return; + if (request.preserveUserPosition && userScrollIntent !== request.userIntent) return; + syncBottomReserve({ followBottom: false }); + const target = Math.max(0, chatArea.scrollHeight - chatArea.clientHeight); + if (Math.abs(chatArea.scrollTop - target) > 0.5) { + chatArea.scrollTop = target; + lastScrollTop = target; + } + enterBottomFollow(); }); } - function isChatNearBottom() { return chatArea.scrollHeight - chatArea.scrollTop - chatArea.clientHeight < 80; } - function captureScrollPosition() { - if (isChatNearBottom()) return null; - const chatTop = chatArea.getBoundingClientRect().top; - const anchorEl = [...chatArea.children].find((element) => element.getBoundingClientRect().bottom > chatTop); + function isChatNearBottom() { + return chatArea.scrollHeight - chatArea.scrollTop - chatArea.clientHeight <= BOTTOM_ATTACH_THRESHOLD; + } + function isChatBottomPinned() { + // Geometry alone cannot reattach bottom mode: content growth can put a + // detached reader inside the threshold without any downward gesture. + // Reattachment happens only in the user-driven scroll handler above or + // through an explicit scrollToBottom request. + return !userDetached; + } + function detachBottomFollow() { + if (!userDetached) viewportModeVersion += 1; + userDetached = true; + cancelScheduledPlacement(); + } + function beginScrollTransaction() { + if (scrollTransaction) { + scrollTransaction.depth += 1; + return; + } + const wasBottomPinned = isChatBottomPinned(); + // Full session snapshots use the same stable disclosure/Node anchor as + // incremental Thinking/IN/OUT and executor updates. An absolute scrollTop + // drifts whenever content above the reader changes height during rebuild. + const readingPosition = wasBottomPinned ? null : captureScrollPosition(); + if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingScrollFrame = null; + pendingRestoreFrame = null; + pendingBottomRequest = null; + pendingRestoreSnapshot = null; + scrollTransaction = { + depth: 1, + wasBottomPinned, + readingPosition, + userIntent: userScrollIntent, + viewportModeVersion, + bottomRequested: false, + preserveUserPosition: true, + }; + } + function endScrollTransaction() { + if (!scrollTransaction) return; + scrollTransaction.depth -= 1; + if (scrollTransaction.depth > 0) return; + const transaction = scrollTransaction; + scrollTransaction = null; + if (userScrollIntent !== transaction.userIntent || viewportModeVersion !== transaction.viewportModeVersion) return; + if (transaction.wasBottomPinned && transaction.bottomRequested) { + enterBottomFollow(); + scrollToBottom({ preserveUserPosition: transaction.preserveUserPosition }); + return; + } + userDetached = true; + restoreScrollPosition(transaction.readingPosition); + } + function captureScrollPosition(preferredAnchor = null, { + force = false, + detachBottom = false, + } = {}) { + const followBottom = isChatBottomPinned(); + // Callers decide whether a render should follow the bottom by consulting + // the same reconciled state. A forced disclosure snapshot records bottom + // mode explicitly instead of turning it into a stale absolute scrollTop. + if (!force && followBottom) return null; + // Detaching must be atomic. A resize/stream update may already have + // queued a bottom placement before the user clicks a disclosure. Leaving + // that request alive makes it race the reading-position restore, which is + // especially visible in polling Node/Input/Conversations cards. + if (detachBottom && !followBottom) detachBottomFollow(); + if (followBottom) return { followBottom: true, userScrollIntent, viewportModeVersion }; + const chatRect = chatArea.getBoundingClientRect(); + let anchorEl = null; + if (preferredAnchor?.isConnected) { + anchorEl = preferredAnchor; + } else { + // Node cards rebuild their inner DOM as polling data arrives. Prefer a + // visible, stable disclosure/node key near the viewport edge so the + // equivalent element can be found after that rebuild. Falling back to + // the outer message is sufficient for ordinary timeline updates. + const stableCandidates = [...chatArea.querySelectorAll("[data-reading-anchor], [data-disclosure-key], [data-step-node-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.bottom > chatRect.top && rect.top < chatRect.bottom; + }); + anchorEl = stableCandidates + .filter((element) => element.getBoundingClientRect().top >= chatRect.top) + .sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top)[0] + || stableCandidates + .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top)[0] + || [...chatArea.children].find((element) => element.getBoundingClientRect().bottom > chatRect.top); + } return { scrollTop: chatArea.scrollTop, anchorEl, - anchorOffset: anchorEl ? anchorEl.getBoundingClientRect().top - chatTop : 0, + anchorKey: anchorEl?.dataset.readingAnchor || anchorEl?.dataset.disclosureKey || anchorEl?.dataset.stepNodeId || null, + anchorKeyType: anchorEl?.dataset.readingAnchor ? "reading" : anchorEl?.dataset.disclosureKey ? "disclosure" : anchorEl?.dataset.stepNodeId ? "step" : null, + anchorOffset: anchorEl ? anchorEl.getBoundingClientRect().top - chatRect.top : 0, userScrollIntent, + viewportModeVersion, }; } + function resolveSnapshotAnchor(position) { + if (position.anchorEl?.isConnected) return position.anchorEl; + if (!position.anchorKey) return null; + const selectors = { + reading: ["readingAnchor", "reading-anchor"], + disclosure: ["disclosureKey", "disclosure-key"], + step: ["stepNodeId", "step-node-id"], + }; + const [attribute, selector] = selectors[position.anchorKeyType] || []; + if (!attribute) return null; + return [...chatArea.querySelectorAll(`[data-${selector}]`)] + .find((element) => element.dataset[attribute] === position.anchorKey) || null; + } function restoreScrollPosition(snapshot) { if (!snapshot) return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (snapshot.userScrollIntent !== userScrollIntent || isChatNearBottom()) return; - if (snapshot.anchorEl?.isConnected) { - const currentOffset = snapshot.anchorEl.getBoundingClientRect().top - chatArea.getBoundingClientRect().top; - chatArea.scrollTop += currentOffset - snapshot.anchorOffset; - } else { - chatArea.scrollTop = snapshot.scrollTop; - } - }); + if (scrollTransaction) return; + if (snapshot.followBottom) { + if (snapshot.userScrollIntent === userScrollIntent && snapshot.viewportModeVersion === viewportModeVersion) { + scrollToBottom({ preserveUserPosition: true }); + } + return; + } + if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + pendingScrollFrame = null; + pendingBottomRequest = null; + // All mutations before the next paint form one visual transaction. Keep + // its earliest snapshot; replacing it with a later image/observer snapshot + // would preserve an already-drifted intermediate layout. + if (!pendingRestoreSnapshot + || pendingRestoreSnapshot.userScrollIntent !== snapshot.userScrollIntent + || pendingRestoreSnapshot.viewportModeVersion !== snapshot.viewportModeVersion) { + pendingRestoreSnapshot = snapshot; + } + if (pendingRestoreFrame !== null) return; + pendingRestoreFrame = requestAnimationFrame(() => { + pendingRestoreFrame = null; + const position = pendingRestoreSnapshot; + pendingRestoreSnapshot = null; + if (!position + || position.userScrollIntent !== userScrollIntent + || position.viewportModeVersion !== viewportModeVersion + || !userDetached) return; + const anchorEl = resolveSnapshotAnchor(position); + if (anchorEl) { + const currentOffset = anchorEl.getBoundingClientRect().top - chatArea.getBoundingClientRect().top; + chatArea.scrollTop += currentOffset - position.anchorOffset; + } else { + chatArea.scrollTop = position.scrollTop; + } + lastScrollTop = chatArea.scrollTop; + }); + } + function updatePreservingReadingPosition(update) { + const followBottom = isChatBottomPinned(); + const readingPosition = followBottom ? null : captureScrollPosition(); + const result = update(); + if (followBottom) scrollToBottom({ preserveUserPosition: true }); + else restoreScrollPosition(readingPosition); + return result; + } + function protectAsyncContentLayout(root) { + root?.querySelectorAll?.("img:not([data-layout-protected])").forEach((img) => { + img.dataset.layoutProtected = "true"; + if (img.complete) return; + img.hidden = true; + const reveal = () => { + updatePreservingReadingPosition(() => { img.hidden = false; }); + }; + img.addEventListener("load", reveal, { once: true }); + img.addEventListener("error", reveal, { once: true }); }); } function appendLiveTurnChild(container, child) { @@ -162,7 +483,7 @@ export function createChatRenderer({ chatArea }) { return firstStepCard ? container.insertBefore(child, firstStepCard) : container.appendChild(child); } function addMessage(role, content, msgIndex, container = chatArea) { - const shouldStick = role === "user" || isChatNearBottom(); + const shouldStick = role === "user" || isChatBottomPinned(); const message = document.createElement("div"); message.className = `message ${role}-message`; if (msgIndex !== undefined) message.dataset.msgIndex = String(msgIndex); @@ -172,6 +493,8 @@ export function createChatRenderer({ chatArea }) { const inner = document.createElement("div"); inner.className = "markdown-content"; inner.innerHTML = renderMarkdown(content || ""); + markReadingAnchors(inner, `message:${msgIndex ?? "live"}`); + protectAsyncContentLayout(inner); bubble.append(inner); message.append(bubble); appendLiveTurnChild(container, message); @@ -179,5 +502,5 @@ export function createChatRenderer({ chatArea }) { return message; } - return { addMessage, appendLiveTurnChild, applyUserAvatarToEl, captureScrollPosition, createAgentAvatarEl, createJsonBlock, isChatNearBottom, renderMarkdown, restoreScrollPosition, scrollToBottom, setUserAvatar }; + return { addMessage, appendLiveTurnChild, applyUserAvatarToEl, beginScrollTransaction, captureScrollPosition, createAgentAvatarEl, createJsonBlock, endScrollTransaction, isChatBottomPinned, markReadingAnchors, protectAsyncContentLayout, renderMarkdown, restoreScrollPosition, scrollToBottom, setUserAvatar, updatePreservingReadingPosition }; } diff --git a/web/vite-frontend/src/features/chat/timeline.js b/web/vite-frontend/src/features/chat/timeline.js index cadf0d48..ddd0982d 100644 --- a/web/vite-frontend/src/features/chat/timeline.js +++ b/web/vite-frontend/src/features/chat/timeline.js @@ -32,6 +32,12 @@ export function compactRepeatedPrefixSnapshots(text) { return compacted; } +function nextTimelineItemId(timeline, prefix) { + const nextId = timeline._nextItemId || 0; + timeline._nextItemId = nextId + 1; + return `${prefix}:${nextId}`; +} + export function upsertTimelineThought(timeline, text) { if (!text) return; const compacted = compactRepeatedPrefixSnapshots(text); @@ -40,14 +46,21 @@ export function upsertTimelineThought(timeline, text) { last.text = compactRepeatedPrefixSnapshots(mergeReplayedText(last.text || "", compacted)); return; } - timeline.push({ type: "thought", text: compacted }); + timeline.push({ type: "thought", timelineId: nextTimelineItemId(timeline, "thought"), text: compacted }); } export function upsertTimelineText(timeline, text) { - for (let index = timeline.length - 1; index >= 0; index--) { - if (timeline[index].type === "text") timeline.splice(index, 1); + if (!text) return; + const compacted = compactRepeatedPrefixSnapshots(text); + const last = timeline[timeline.length - 1]; + if (last?.type === "text") { + // Only the currently streaming, contiguous text block is mutable. Text + // before a Thinking/IN/OUT item is historical content and must retain its + // position and DOM identity when a later text block arrives. + last.text = compactRepeatedPrefixSnapshots(mergeReplayedText(last.text || "", compacted)); + return; } - if (text) timeline.push({ type: "text", text }); + timeline.push({ type: "text", timelineId: nextTimelineItemId(timeline, "text"), text: compacted }); } function timelineEventKey(event) { @@ -64,11 +77,15 @@ export function upsertTimelineEvent(timeline, event) { (item.type === "function_call" || item.type === "function_response") && timelineEventKey(item) === eventKey ) { + // Keep the UI identity when an incoming event refreshes an existing + // call/response. Its expanded state must survive the refresh. + event.timelineId = item.timelineId; timeline[index] = event; return; } } const last = timeline[timeline.length - 1]; if (last && JSON.stringify(last) === JSON.stringify(event)) return; + event.timelineId ||= nextTimelineItemId(timeline, event.type); timeline.push(event); -} \ No newline at end of file +} diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index 520049c9..21830ba4 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -1,4 +1,5 @@ import { Network, DataSet } from "vis-network/standalone"; +import { createDisclosureController } from "../ui/disclosureState.js"; const NODE_COLORS = { orchestrator: { core: "124, 58, 237", edge: "196, 181, 253", font: "#f5f3ff" }, @@ -19,6 +20,18 @@ const STATUS_COLORS = { const rgba = (rgb, alpha) => `rgba(${rgb}, ${alpha})`; +// These distances describe time, rather than graph depth. In particular an +// execution batch gets its own row even though every execution node still has +// the same planning node as its real parent. +const VINE_BATCH_GAP = 92; +const VINE_BATCH_STAGGER = 54; +// Keep task chains legible vertically while batches themselves still use the +// tighter stagger below. +const VINE_DESCENDANT_GAP = 70; +const VINE_NODE_GAP = 64; +const VINE_PLANNER_GAP = 460; +const VINE_STEM_CLEARANCE = 42; + const STATUS_ALIASES = { completed: "success", succeeded: "success", @@ -46,12 +59,14 @@ export class AgentGraphView { this._edges = new DataSet([]); this._network = null; this._pollInterval = null; + this._eventStream = null; this._didInitialFit = false; this._pendingFit = true; this._animationFrame = null; this._lastAnimationPaint = 0; this._motionTime = 0; this._activeEdges = []; + this._vineEdges = []; this._hasRunningNodes = false; this._reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; this._detailEl = document.getElementById("graph-detail"); @@ -67,37 +82,24 @@ export class AgentGraphView { this._detailConversation = document.getElementById("detail-conversation"); this._nodeData = {}; this._activeDetailNodeId = null; - this._init(); - } - - _captureOpenToolCallKeys() { - const openKeys = new Set(); - this._detailToolcalls?.querySelectorAll("details[data-toolcall-key][open]")?.forEach((el) => { - openKeys.add(el.getAttribute("data-toolcall-key")); - }); - return openKeys; - } - - _restoreOpenToolCallKeys(openKeys) { - if (!openKeys?.size) return; - this._detailToolcalls?.querySelectorAll("details[data-toolcall-key]")?.forEach((el) => { - el.open = openKeys.has(el.getAttribute("data-toolcall-key")); + this._detailDisclosures = createDisclosureController({ + captureScrollPosition: () => ({ scrollTop: this._detailEl.scrollTop }), + restoreScrollPosition: (position) => { + requestAnimationFrame(() => requestAnimationFrame(() => { + if (position) this._detailEl.scrollTop = position.scrollTop; + })); + }, }); + this._init(); } _init() { const edgeColors = this._edgeColors(); const options = { - layout: { - hierarchical: { - direction: "UD", - sortMethod: "directed", - nodeSpacing: 76, - levelSeparation: 86, - blockShifting: true, - edgeMinimization: true, - }, - }, + // Agent activity uses a chronology-aware layout below. A DAG layout + // would put every child of a planner at the same depth and erase the + // distinction between successive planning rounds. + layout: { hierarchical: false }, physics: { enabled: false }, edges: { arrows: { to: { enabled: true, scaleFactor: 0.72 } }, @@ -129,6 +131,7 @@ export class AgentGraphView { if (params.nodes.length) this._showDetail(params.nodes[0]); }); this._network.on("deselectNode", () => this._hideDetail()); + this._network.on("beforeDrawing", (ctx) => this._drawVines(ctx)); this._network.on("afterDrawing", (ctx) => this._drawActiveFlow(ctx)); window.addEventListener("matcreator-theme-change", () => this._applyTheme()); this._detailClose?.addEventListener("click", () => { @@ -428,41 +431,282 @@ export class AgentGraphView { this._animationFrame = requestAnimationFrame(animate); } - _computeLevels(rawNodes, edges) { - const nodeIds = rawNodes.map((node) => node.id); - const nodeIdSet = new Set(nodeIds); - const children = Object.fromEntries(nodeIds.map((id) => [id, []])); - const inDegree = Object.fromEntries(nodeIds.map((id) => [id, 0])); + _timeKey(node) { + const value = node?.start_time ? new Date(node.start_time).getTime() : NaN; + return Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER; + } + + _executionBatchId(node, plannerId) { + // batch_id is optional for older graph snapshots. Untagged direct siblings + // belong to one legacy batch rather than being split into one layer per + // node: sequential dispatch is still work from the same planning round. + // New snapshots carry explicit IDs, so replanning rounds remain separate. + return String( + node.batch_id + ?? node.execution_batch_id + ?? node.input?.batch_id + ?? node.input?.execution_batch_id + ?? `legacy:${plannerId}`, + ); + } + + _chronologicalTaskRows(nodeIds, nodeMap) { + const timed = nodeIds.map((id) => { + const node = nodeMap[id]; + const startValue = node?.start_time ? new Date(node.start_time).getTime() : NaN; + const endValue = node?.end_time ? new Date(node.end_time).getTime() : NaN; + return { + id, + start: startValue, + hasStart: Number.isFinite(startValue), + // An unfinished task keeps its row open. That makes concurrently + // running work stay together instead of being rendered as a sequence. + end: Number.isFinite(endValue) ? endValue : Number.MAX_SAFE_INTEGER, + }; + }).sort((a, b) => { + if (a.hasStart !== b.hasStart) return a.hasStart ? -1 : 1; + return a.start - b.start || a.id.localeCompare(b.id); + }); + + if (!timed.some((task) => task.hasStart)) return [nodeIds]; + + const rows = []; + let rowEnd = -Infinity; + timed.forEach((task) => { + // A snapshot without a start time has no reliable chronology. Keep it + // beside the current wave instead of inventing a sequential ordering. + if (!task.hasStart) { + rows[rows.length - 1].push(task.id); + return; + } + if (!rows.length || task.start >= rowEnd) { + rows.push([task.id]); + rowEnd = task.end; + } else { + rows[rows.length - 1].push(task.id); + rowEnd = Math.max(rowEnd, task.end); + } + }); + return rows; + } + + _sequenceTaskDisplayEdges(displayEdges, nodeMap) { + const directTaskEdges = new Map(); + displayEdges.forEach((edge) => { + if (nodeMap[edge.from]?.type !== "execution" || nodeMap[edge.to]?.type !== "step") return; + if (!directTaskEdges.has(edge.from)) directTaskEdges.set(edge.from, []); + directTaskEdges.get(edge.from).push(edge); + }); + if (!directTaskEdges.size) return displayEdges; + + const replacedEdgeIds = new Set(); + const sequenceEdges = []; + directTaskEdges.forEach((taskEdges, executionId) => { + const rows = this._chronologicalTaskRows(taskEdges.map((edge) => edge.to), nodeMap); + if (rows.length < 2) return; + taskEdges.forEach((edge) => replacedEdgeIds.add(edge.id)); + + // The first concurrent wave keeps E as its source. Later waves receive + // their visual connection from the preceding wave, yielding E -> 1 -> 2 + // for a sequential pair while preserving same-row parallelism. + rows[0].forEach((to) => sequenceEdges.push({ + id: `sequence__${executionId}__${to}`, + from: executionId, + to, + })); + for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) { + const previous = rows[rowIndex - 1]; + // A following wave begins only after the preceding parallel wave has + // completed. Draw every predecessor so a join such as 1 + 2 -> 3 + // keeps both dependency lines rather than silently choosing task 1. + previous.forEach((from) => rows[rowIndex].forEach((to) => sequenceEdges.push({ + id: `sequence__${executionId}__${from}__${to}`, + from, + to, + }))); + } + }); + return [ + ...displayEdges.filter((edge) => !replacedEdgeIds.has(edge.id)), + ...sequenceEdges, + ]; + } - // Levels describe hierarchy, not elapsed time. Tasks with the same parent - // therefore remain siblings on the same row even if they ran sequentially. + _computeVineLayout(rawNodes, edges) { + const nodeMap = Object.fromEntries(rawNodes.map((node) => [node.id, node])); + const children = Object.fromEntries(rawNodes.map((node) => [node.id, []])); (edges || []).forEach((edge) => { - if (!nodeIdSet.has(edge.from) || !nodeIdSet.has(edge.to)) return; - children[edge.from].push(edge.to); - inDegree[edge.to] += 1; + if (children[edge.from] && nodeMap[edge.to]) children[edge.from].push(edge.to); }); + Object.values(children).forEach((ids) => ids.sort((a, b) => + this._timeKey(nodeMap[a]) - this._timeKey(nodeMap[b]) || String(a).localeCompare(String(b)))); + + const positions = {}; + const placed = new Set(); + const planners = rawNodes.filter((node) => node.type === "planning") + .sort((a, b) => this._timeKey(a) - this._timeKey(b) || a.id.localeCompare(b.id)); + const plannerCount = planners.length; + + planners.forEach((planner, plannerIndex) => { + const plannerX = (plannerIndex - (plannerCount - 1) / 2) * VINE_PLANNER_GAP; + positions[planner.id] = { x: plannerX, y: 0 }; + placed.add(planner.id); + + const batches = new Map(); + children[planner.id] + .filter((id) => nodeMap[id]?.type === "execution") + .forEach((id) => { + const batchId = this._executionBatchId(nodeMap[id], planner.id); + if (!batches.has(batchId)) batches.set(batchId, []); + batches.get(batchId).push(id); + }); + const orderedBatches = [...batches.entries()].sort(([aId, a], [bId, b]) => { + const aTime = Math.min(...a.map((id) => this._timeKey(nodeMap[id]))); + const bTime = Math.min(...b.map((id) => this._timeKey(nodeMap[id]))); + return aTime - bTime || aId.localeCompare(bId); + }); - const levels = {}; - const queue = nodeIds.filter((id) => inDegree[id] === 0); - queue.forEach((id) => { levels[id] = 0; }); + let previousBatchY = VINE_BATCH_GAP - VINE_BATCH_STAGGER; + // Alternating branches use independent vertical lanes. A new branch on + // the opposite side may tuck in after a small stagger; returning to a + // side waits until that side's existing leaf fan has cleared. + const sideClearY = new Map([[-1, VINE_BATCH_GAP], [1, VINE_BATCH_GAP]]); + orderedBatches.forEach(([, executionIds], batchIndex) => { + executionIds.sort((a, b) => this._timeKey(nodeMap[a]) - this._timeKey(nodeMap[b]) || a.localeCompare(b)); + const isLatestBatch = batchIndex === orderedBatches.length - 1; + // Measure each E subtree independently. Pooling all sibling task + // nodes into global rows made their branches cross and obscured which + // execution owned each task. + const subtrees = executionIds.map((rootId) => { + const rows = []; + const seen = new Set([rootId]); + let frontier = [rootId]; + while (frontier.length) { + const next = []; + frontier.forEach((parentId) => children[parentId].forEach((childId) => { + if (!seen.has(childId) && nodeMap[childId]?.type !== "execution") { + seen.add(childId); + next.push(childId); + } + })); + if (!next.length) break; + rows.push(next); + frontier = next; + } + const widestRow = Math.max(0, ...rows.map((ids) => (ids.length - 1) * VINE_NODE_GAP)); + return { + rootId, + rows, + maxDepth: rows.length, + width: Math.max(VINE_NODE_GAP, widestRow + VINE_NODE_GAP), + }; + }); + const batchWidth = subtrees.reduce((total, subtree) => total + subtree.width, 0); + const maxDepth = Math.max(0, ...subtrees.map((subtree) => subtree.maxDepth)); + const subtreeHalfWidth = batchWidth / 2 + this._nodeRadius({ type: "step" }); + const side = batchIndex % 2 === 0 ? 1 : -1; + const nextStaggerY = previousBatchY + VINE_BATCH_STAGGER; + const batchY = isLatestBatch + // The centered tip shares horizontal space with both historical + // sides. Place it below the deepest task generation from either + // side, not merely below the preceding execution node. + ? Math.max(nextStaggerY, ...sideClearY.values()) + : Math.max(nextStaggerY, sideClearY.get(side)); + const batchCenterX = isLatestBatch + ? plannerX + : plannerX + side * (subtreeHalfWidth + VINE_STEM_CLEARANCE); + + let subtreeLeft = batchCenterX - batchWidth / 2; + subtrees.forEach((subtree) => { + const rootX = subtreeLeft + subtree.width / 2; + positions[subtree.rootId] = { x: rootX, y: batchY }; + placed.add(subtree.rootId); + subtree.rows.forEach((ids, depthIndex) => { + const rowWidth = (ids.length - 1) * VINE_NODE_GAP; + ids.forEach((id, index) => { + positions[id] = { + x: rootX + index * VINE_NODE_GAP - rowWidth / 2, + y: batchY + (depthIndex + 1) * VINE_DESCENDANT_GAP, + }; + placed.add(id); + }); + }); + subtreeLeft += subtree.width; + }); + previousBatchY = batchY; + if (!isLatestBatch) { + sideClearY.set( + side, + batchY + maxDepth * VINE_DESCENDANT_GAP + VINE_BATCH_STAGGER, + ); + } + }); + }); - while (queue.length) { - const parentId = queue.shift(); - children[parentId].forEach((childId) => { - levels[childId] = Math.max( - levels[childId] ?? 0, - (levels[parentId] ?? 0) + 1, + // Keep the orchestrator immediately above its planners. Any malformed or + // unrelated node remains visible in a small fallback strip instead of + // being silently omitted from the graph. + rawNodes.filter((node) => node.type === "orchestrator").forEach((node, index) => { + positions[node.id] = { x: index * VINE_PLANNER_GAP, y: -VINE_BATCH_GAP }; + placed.add(node.id); + }); + rawNodes.filter((node) => !placed.has(node.id)).forEach((node, index) => { + positions[node.id] = { x: index * VINE_NODE_GAP, y: VINE_BATCH_GAP }; + }); + return positions; + } + + _drawVines(ctx) { + if (!this._network || !this._vineEdges.length) return; + const positions = this._network.getPositions(); + const isLight = document.body.dataset.theme === "light"; + const color = isLight ? "#8290a3" : "#64748b"; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 1.7; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + this._vineEdges.forEach(({ from, branches }) => { + const source = positions[from]; + if (!source || !branches.length) return; + const sourceY = source.y + this._nodeRadius(this._nodeData[from]) + 1; + const stemEndY = Math.max(...branches.map(({ to }) => { + const target = positions[to]; + return target ? target.y - this._nodeRadius(this._nodeData[to]) - 34 : sourceY; + })); + + // The stem is drawn once, so later planning rounds visibly extend the + // same P connection rather than appearing as unrelated long edges. + ctx.beginPath(); + ctx.moveTo(source.x, sourceY); + ctx.lineTo(source.x, stemEndY); + ctx.stroke(); + + branches.forEach(({ to }) => { + const target = positions[to]; + if (!target) return; + const targetY = target.y - this._nodeRadius(this._nodeData[to]) - 1; + const branchY = targetY - 34; + ctx.beginPath(); + ctx.moveTo(source.x, branchY); + ctx.bezierCurveTo( + source.x, branchY + 18, + target.x, branchY - 18, + target.x, targetY, ); - inDegree[childId] -= 1; - if (inDegree[childId] === 0) queue.push(childId); - }); - } + ctx.stroke(); - // Keep malformed/cyclic payloads visible rather than dropping their nodes. - nodeIds.forEach((id) => { - if (!(id in levels)) levels[id] = 0; + ctx.beginPath(); + ctx.moveTo(target.x, targetY); + ctx.lineTo(target.x - 4, targetY - 7); + ctx.lineTo(target.x + 4, targetY - 7); + ctx.closePath(); + ctx.fill(); + }); }); - return levels; + ctx.restore(); } _buildDisplayEdges(rawNodes, edges) { @@ -488,10 +732,20 @@ export class AgentGraphView { }); }); - // Phase nodes are logged as orchestrator children because the orchestrator - // invokes them. For display, group each execution/testing phase beneath - // the planning invocation whose context produced it. + // Newer graph records persist the actual planning parent. Older sessions + // logged every phase under the orchestrator, so retain temporal grouping as + // a backwards-compatible fallback. childPhaseNodes.forEach((node) => { + const persistedParent = nodeMap[node.parent_id]; + if (persistedParent?.type === "planning") { + displayEdges.push({ + id: `phase__${persistedParent.id}__${node.id}`, + from: persistedParent.id, + to: node.id, + }); + return; + } + const nodeStart = node.start_time ? new Date(node.start_time).getTime() : Infinity; let parentPlanning = null; for (const planning of planningNodes) { @@ -527,7 +781,7 @@ export class AgentGraphView { }); }); - return displayEdges; + return this._sequenceTaskDisplayEdges(displayEdges, nodeMap); } _resizeSurface() { @@ -579,8 +833,24 @@ export class AgentGraphView { color: STATUS_COLORS.running, phase: (index * 0.173) % 1, })); - const levels = this._computeLevels(rawNodes, displayEdges); - this._resizeSurface(levels); + const positions = this._computeVineLayout(rawNodes, displayEdges); + const vineEdgeIds = new Set(displayEdges + .filter((edge) => rawNodeMap[edge.from]?.type === "planning" && rawNodeMap[edge.to]?.type === "execution") + .map((edge) => edge.id || `${edge.from}__${edge.to}`)); + const vineTargetsByPlanner = new Map(); + displayEdges.forEach((edge) => { + if (!vineEdgeIds.has(edge.id || `${edge.from}__${edge.to}`)) return; + if (!vineTargetsByPlanner.has(edge.from)) vineTargetsByPlanner.set(edge.from, []); + vineTargetsByPlanner.get(edge.from).push(edge.to); + }); + this._vineEdges = [...vineTargetsByPlanner.entries()].map(([from, targetIds]) => ({ + from, + // This one object produces one shared stem and a branch for every + // direct execution relation. It intentionally does not replace edges + // in graphData: every E still belongs directly to its planner. + branches: targetIds.map((to) => ({ to })), + })); + this._resizeSurface(); const nextNodeIds = new Set(rawNodes.map((raw) => raw.id)); const nextEdgeIds = new Set(displayEdges.map((e) => e.id || `${e.from}__${e.to}`)); const topologyChanged = @@ -595,7 +865,10 @@ export class AgentGraphView { rawNodes.forEach((raw) => { const vis = this._visNode(raw); - vis.level = levels[raw.id] ?? 0; + const position = positions[raw.id] || { x: 0, y: 0 }; + vis.x = position.x; + vis.y = position.y; + vis.fixed = { x: true, y: true }; if (this._nodes.get(raw.id)) { this._nodes.update(vis); } else { @@ -607,21 +880,23 @@ export class AgentGraphView { if (!nextEdgeIds.has(edgeId)) this._edges.remove(edgeId); }); - const existingEdgeIds = new Set(this._edges.getIds()); displayEdges.forEach((e) => { const edgeId = e.id || `${e.from}__${e.to}`; - if (!existingEdgeIds.has(edgeId)) { - this._edges.add({ - id: edgeId, - from: e.from, - to: e.to, - hidden: false, - physics: false, - width: 1.35, - color: this._edgeColors(), - smooth: { type: "cubicBezier", forceDirection: "vertical" }, - }); - } + const visEdge = { + id: edgeId, + from: e.from, + to: e.to, + // Planner -> execution links are painted as routed vines in + // beforeDrawing. The edge itself stays in the DataSet, preserving the + // real graph topology for interaction and future consumers. + hidden: vineEdgeIds.has(edgeId), + physics: false, + width: 1.35, + color: this._edgeColors(), + smooth: { type: "cubicBezier", forceDirection: "vertical" }, + }; + if (this._edges.get(edgeId)) this._edges.update(visEdge); + else this._edges.add(visEdge); }); if (rawNodes.length > 0 && (topologyChanged || !this._didInitialFit || this._pendingFit)) { @@ -641,10 +916,26 @@ export class AgentGraphView { startPolling(sessionId) { this._currentSessionId = sessionId; this._poll(sessionId); - this._pollInterval = setInterval(() => this._poll(sessionId), 2000); + this._eventStream?.close(); + this._eventStream = new EventSource(`/api/agent-graph/${encodeURIComponent(sessionId)}/events`); + this._eventStream.onmessage = (event) => { + try { + if (sessionId !== this._currentSessionId) return; + this.update(JSON.parse(event.data)); + } catch (_) { + // Ignore a malformed snapshot; EventSource will deliver the next one. + } + }; + // Keep a low-frequency fallback for deployments running an older web + // backend that does not yet expose the graph event endpoint. + this._eventStream.onerror = () => { + if (!this._pollInterval) this._pollInterval = setInterval(() => this._poll(sessionId), 2000); + }; } stopPolling() { + this._eventStream?.close(); + this._eventStream = null; if (this._pollInterval) { clearInterval(this._pollInterval); this._pollInterval = null; @@ -672,6 +963,7 @@ export class AgentGraphView { this._pendingFit = true; this._hasRunningNodes = false; this._activeEdges = []; + this._detailDisclosures.clear(); if (this._animationFrame !== null) cancelAnimationFrame(this._animationFrame); this._animationFrame = null; this._lastAnimationPaint = 0; @@ -686,7 +978,6 @@ export class AgentGraphView { this._activeDetailNodeId = nodeId; const preserveScroll = Boolean(options.preserveScroll); const prevScrollTop = preserveScroll ? this._detailEl.scrollTop : 0; - const prevOpenToolCallKeys = preserveScroll ? this._captureOpenToolCallKeys() : new Set(); this._detailLabel.textContent = raw.label; this._detailStatus.textContent = raw.status; this._detailStatus.className = `badge badge-${raw.status}`; @@ -747,10 +1038,10 @@ export class AgentGraphView { this._detailToolcallsCount.textContent = toolCalls.length; this._detailToolcalls.innerHTML = ""; if (toolCalls.length) { - toolCalls.forEach((tc) => { + toolCalls.forEach((tc, index) => { const d = document.createElement("details"); d.className = "timeline-function-call"; - d.setAttribute("data-toolcall-key", tc.id || `${tc.name}:${tc.start_time || ""}`); + const disclosureKey = `detail:${nodeId}:tool:${tc.id || `${index}:${tc.name}:${tc.start_time || ""}`}`; const dur = tc.start_time && tc.end_time ? ` (${((new Date(tc.end_time) - new Date(tc.start_time)) / 1000).toFixed(1)}s)` : ""; @@ -768,6 +1059,7 @@ export class AgentGraphView { this._getStructurePaths(tc).forEach((path) => { d.appendChild(this._createStructureViewButton(path)); }); + this._detailDisclosures.wire(d, disclosureKey); this._detailToolcalls.appendChild(d); }); document.getElementById("detail-toolcalls-row").style.display = ""; @@ -779,7 +1071,7 @@ export class AgentGraphView { const conversation = raw.type === "step" ? [] : (raw.conversation || []); this._detailConversation.innerHTML = ""; if (conversation.length) { - conversation.forEach((evt) => { + conversation.forEach((evt, index) => { const d = document.createElement("details"); d.className = `timeline-${evt.type}`; const s = document.createElement("summary"); @@ -787,6 +1079,7 @@ export class AgentGraphView { s.textContent = `${icon} [${evt.author}] ${evt.type}`; d.appendChild(s); d.appendChild(this._createJsonBlock(evt.content)); + this._detailDisclosures.wire(d, `detail:${nodeId}:conversation:${index}:${evt.timestamp || ""}:${evt.type || ""}`); this._detailConversation.appendChild(d); }); document.getElementById("detail-conversation-row").style.display = ""; @@ -798,7 +1091,6 @@ export class AgentGraphView { if (raw.type === "step" && options.scrollToStep !== false) this._stepExecutionFeed.highlight(raw.id); this._syncPanelResizerVisibility(); if (preserveScroll) { - this._restoreOpenToolCallKeys(prevOpenToolCallKeys); this._detailEl.scrollTop = prevScrollTop; } } @@ -831,10 +1123,7 @@ export class StepExecutionFeed { constructor(dependencies) { this._chatArea = dependencies.chatArea; this._isSending = dependencies.isSending; - this._isChatNearBottom = dependencies.isChatNearBottom; - this._captureScrollPosition = dependencies.captureScrollPosition; - this._restoreScrollPosition = dependencies.restoreScrollPosition; - this._scrollToBottom = dependencies.scrollToBottom; + this._updatePreservingReadingPosition = dependencies.updatePreservingReadingPosition; this._createAgentAvatarEl = dependencies.createAgentAvatarEl; this._stepFeedTitle = dependencies.stepFeedTitle; this._formatStepDuration = dependencies.formatStepDuration; @@ -844,8 +1133,7 @@ export class StepExecutionFeed { this._requestStepCancellation = dependencies.requestStepCancellation; this._createArtifactListItem = dependencies.createArtifactListItem; this._cards = new Map(); - this._userOpen = new Map(); - this._nestedOpen = new Map(); + this._disclosures = dependencies.disclosureController; this._highlightedId = null; this._liveAnchorEl = null; this._liveContainerEl = null; @@ -855,10 +1143,9 @@ export class StepExecutionFeed { this._childNodes = new Map(); } - reset() { + reset({ preserveDisclosures = false } = {}) { this._cards.clear(); - this._userOpen.clear(); - this._nestedOpen.clear(); + if (!preserveDisclosures) this._disclosures.clear(); this._highlightedId = null; this._liveAnchorEl = null; this._liveContainerEl = null; @@ -868,6 +1155,10 @@ export class StepExecutionFeed { this._childNodes = new Map(); } + captureDisclosureState() { + this._disclosures.capture(this._chatArea); + } + startLiveTurn(anchorEl, startedAt = Date.now(), hostEl = null) { this._liveAnchorEl = anchorEl || null; this._liveStartedAt = startedAt; @@ -928,15 +1219,13 @@ export class StepExecutionFeed { for (const nodeId of this._cards.keys()) { if (!seen.has(nodeId)) { this._cards.delete(nodeId); - this._nestedOpen.delete(nodeId); + this._disclosures.deletePrefix(`step:${nodeId}:`); } } - const shouldStick = this._isChatNearBottom(); - const scrollPosition = shouldStick ? null : this._captureScrollPosition(); - rootSteps.forEach((node) => this._upsert(node)); - if (shouldStick) this._scrollToBottom({ preserveUserPosition: true }); - else this._restoreScrollPosition(scrollPosition); + this._updatePreservingReadingPosition(() => { + rootSteps.forEach((node) => this._upsert(node)); + }); } setHierarchy(stepNodes) { @@ -1163,8 +1452,8 @@ export class StepExecutionFeed { bubble.className = "message-bubble step-feed-bubble"; const details = document.createElement("details"); details.className = "step-feed-details"; - details.addEventListener("toggle", () => { - this._userOpen.set(node.id, details.open); + this._disclosures.wire(details, `step:${node.id}:card`, { + defaultOpen: node.status === "running", }); bubble.appendChild(details); outer.appendChild(bubble); @@ -1172,19 +1461,8 @@ export class StepExecutionFeed { } _wireNested(nodeId, key, details) { - let nodeState = this._nestedOpen.get(nodeId); - if (!nodeState) { - nodeState = new Map(); - this._nestedOpen.set(nodeId, nodeState); - } - if (nodeState.has(key)) { - details.open = nodeState.get(key); - } details.dataset.stepNestedKey = key; - details.addEventListener("toggle", (event) => { - if (event.target !== details) return; - nodeState.set(key, details.open); - }); + this._disclosures.wire(details, `step:${nodeId}:nested:${key}`); return details; } @@ -1197,8 +1475,14 @@ export class StepExecutionFeed { bubble?.querySelector(":scope > .step-feed-child-section")?.remove(); const details = outer.querySelector(".step-feed-details"); - const userChoice = this._userOpen.get(node.id); - details.open = userChoice === undefined ? node.status === "running" : userChoice; + const cardKey = `step:${node.id}:card`; + const userChoice = this._disclosures.state.get(cardKey); + // Preserve an already-open running card when polling changes its status + // to completed/cancelled. Otherwise that status transition collapses the + // Node before the following session snapshot can preserve its UI state. + details.open = userChoice === undefined + ? details.open || node.status === "running" + : userChoice; details.innerHTML = ""; const summary = document.createElement("summary"); @@ -1242,6 +1526,18 @@ export class StepExecutionFeed { body.appendChild(p); } + const liveResponse = this._latestStreamedResponse(node); + if (node.status === "running" && liveResponse) { + const response = document.createElement("div"); + response.className = "step-feed-live-response"; + const label = document.createElement("span"); + label.textContent = "Live response"; + const content = document.createElement("span"); + content.textContent = liveResponse; + response.append(label, content); + body.appendChild(response); + } + if (node.input && Object.keys(node.input).length) { body.appendChild(this._wireNested(node.id, "input", this._renderStepInput(node.input))); } @@ -1281,10 +1577,9 @@ export class StepExecutionFeed { const key = `conversation:${idx}:${evt.timestamp || ""}:${evt.type || ""}:${evt.author || ""}`; sectionDetails.appendChild(this._wireNested(node.id, key, this._renderStepConversationEvent(evt))); }); - sectionDetails.addEventListener("toggle", () => { - sectionSummary.classList.toggle("open", sectionDetails.open); - }); section.appendChild(this._wireNested(node.id, "section:conversation", sectionDetails)); + sectionSummary.classList.toggle("open", sectionDetails.open); + sectionDetails.addEventListener("toggle", () => sectionSummary.classList.toggle("open", sectionDetails.open)); body.appendChild(section); } @@ -1302,10 +1597,9 @@ export class StepExecutionFeed { const key = `tool:${idx}:${tc.name || ""}:${tc.start_time || ""}`; sectionDetails.appendChild(this._wireNested(node.id, key, this._renderStepToolCall(tc))); }); - sectionDetails.addEventListener("toggle", () => { - sectionSummary.classList.toggle("open", sectionDetails.open); - }); section.appendChild(this._wireNested(node.id, "section:toolcalls", sectionDetails)); + sectionSummary.classList.toggle("open", sectionDetails.open); + sectionDetails.addEventListener("toggle", () => sectionSummary.classList.toggle("open", sectionDetails.open)); body.appendChild(section); } @@ -1334,6 +1628,15 @@ export class StepExecutionFeed { details.appendChild(body); } + + _latestStreamedResponse(node) { + const conversation = node.conversation || []; + for (let index = conversation.length - 1; index >= 0; index -= 1) { + const event = conversation[index]; + if (["text", "thought"].includes(event.type) && event.content) return String(event.content); + } + return ""; + } } // --------------------------------------------------------------------------- diff --git a/web/vite-frontend/src/features/graphs/ExecutionPlanView.js b/web/vite-frontend/src/features/graphs/ExecutionPlanView.js index 2cf9d196..00a1a92e 100644 --- a/web/vite-frontend/src/features/graphs/ExecutionPlanView.js +++ b/web/vite-frontend/src/features/graphs/ExecutionPlanView.js @@ -21,6 +21,10 @@ const PLAN_GRAPH_DEFAULT_LAYOUT = { edgeMinimization: true, }; +const PLAN_GRAPH_MIN_SCALE = 0.15; +const PLAN_GRAPH_MAX_SCALE = 4; +const PLAN_GRAPH_WHEEL_ZOOM_FACTOR = 0.001; + export class ExecutionPlanView { constructor(containerId, options = {}) { this._container = document.getElementById(containerId); @@ -69,7 +73,9 @@ export class ExecutionPlanView { tooltipDelay: 200, dragNodes: true, dragView: true, - zoomView: true, + // Wheel zoom is handled below so high-resolution wheel events scale + // proportionally instead of becoming fixed, repeated zoom steps. + zoomView: false, }, }; this._network = new Network( @@ -78,6 +84,41 @@ export class ExecutionPlanView { options ); this._network.on("beforeDrawing", (ctx) => this._drawCanvasGrid(ctx)); + this._container.addEventListener("wheel", (event) => this._handleWheelZoom(event), { passive: false }); + } + + _handleWheelZoom(event) { + if (!this._network || event.deltaY === 0) return; + + // vis-network applies a fixed zoom step to every wheel event. High- + // resolution mice and touchpads can emit dozens of tiny events for a + // single scroll, causing runaway zoom. Scale by the actual wheel distance + // instead, and cap a malformed event to one normal wheel notch. + event.preventDefault(); + const delta = clamp(event.deltaY, -100, 100); + const oldScale = this._network.getScale(); + const newScale = clamp( + oldScale * Math.exp(-delta * PLAN_GRAPH_WHEEL_ZOOM_FACTOR), + PLAN_GRAPH_MIN_SCALE, + PLAN_GRAPH_MAX_SCALE, + ); + if (newScale === oldScale) return; + + const bounds = this._container.getBoundingClientRect(); + const pointer = this._network.DOMtoCanvas({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + const position = this._network.getViewPosition(); + const scaleRatio = oldScale / newScale; + this._network.moveTo({ + position: { + x: pointer.x - (pointer.x - position.x) * scaleRatio, + y: pointer.y - (pointer.y - position.y) * scaleRatio, + }, + scale: newScale, + animation: false, + }); } _computeLevels(nodeIds, rawEdges) { @@ -665,13 +706,13 @@ export class ExecutionPlanView { zoomIn() { if (!this._network) return; - const scale = this._network.getScale() * 1.3; + const scale = clamp(this._network.getScale() * 1.3, PLAN_GRAPH_MIN_SCALE, PLAN_GRAPH_MAX_SCALE); this._network.moveTo({ scale, animation: { duration: 200, easingFunction: "easeInOutQuad" } }); } zoomOut() { if (!this._network) return; - const scale = this._network.getScale() / 1.3; + const scale = clamp(this._network.getScale() / 1.3, PLAN_GRAPH_MIN_SCALE, PLAN_GRAPH_MAX_SCALE); this._network.moveTo({ scale, animation: { duration: 200, easingFunction: "easeInOutQuad" } }); } diff --git a/web/vite-frontend/src/features/session/fileTree.js b/web/vite-frontend/src/features/session/fileTree.js index 6ae7046f..dae98ed5 100644 --- a/web/vite-frontend/src/features/session/fileTree.js +++ b/web/vite-frontend/src/features/session/fileTree.js @@ -1,12 +1,17 @@ const STRUCTURE_EXTENSIONS = new Set([".cif", ".xyz", ".extxyz", ".vasp"]); -const STRUCTURE_NAMES = new Set(["poscar", "contcar"]); const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg"]); +function isVaspStructureFilename(name) { + // VASP workflows often generate variants such as POSCAR_water_layer2 or + // POSCAR-2_water6. These have no informative extension, but are still POSCARs. + return /^(?:poscar|contcar)(?:[_.-].*)?$/i.test(name); +} + export function classifyPath(path) { const name = path.split("/").pop(); const dotIndex = name.lastIndexOf("."); const extension = dotIndex >= 0 ? name.slice(dotIndex).toLowerCase() : ""; - if (STRUCTURE_EXTENSIONS.has(extension) || STRUCTURE_NAMES.has(name.toLowerCase())) return "structure"; + if (STRUCTURE_EXTENSIONS.has(extension) || isVaspStructureFilename(name)) return "structure"; if (IMAGE_EXTENSIONS.has(extension)) return "image"; return "artifact"; } @@ -131,4 +136,4 @@ export function createSessionFileTree({ getSessionId, pathToApiUrl, openStructur } return { render }; -} \ No newline at end of file +} diff --git a/web/vite-frontend/src/features/session/runtime.js b/web/vite-frontend/src/features/session/runtime.js index 313c4c50..ac5a231c 100644 --- a/web/vite-frontend/src/features/session/runtime.js +++ b/web/vite-frontend/src/features/session/runtime.js @@ -1,3 +1,9 @@ +import { + upsertTimelineEvent, + upsertTimelineText, + upsertTimelineThought, +} from "../chat/timeline.js"; + /** Owns restoring a persisted session and reconnecting to an active managed run. */ export function createSessionRuntime({ state, @@ -14,12 +20,15 @@ export function createSessionRuntime({ addMessage, addAgentTimelineMessage, addPlanApprovalActions, + beginScrollTransaction, + endScrollTransaction, renderSessionBanner, renderSessionFilesTree, refreshSessionFiles, generateSessionSummary, workdirDisplay, }) { + let renderedSessionKey = null; // Plan approval is UI-derived from persisted events. A cancelled turn can // still contain a successful validation event, so remember that its prompt // was dismissed until the user deliberately starts another turn. @@ -69,31 +78,24 @@ export function createSessionRuntime({ return responses; } - function eventToTimelineParts(event, responsesById, pairedResponseIds = new Set()) { - const timeline = []; - let accumulatedText = ""; + function eventToTimelineParts(event, responsesById, pairedResponseIds = new Set(), timeline = []) { for (const part of event.content?.parts || []) { if (part.thought) { - timeline.push({ type: "thought", text: part.text || "" }); + upsertTimelineThought(timeline, part.text || ""); } else if (part.functionCall || part.function_call) { const call = part.functionCall || part.function_call; const matchedResponse = responsesById[call.id]; - timeline.push({ type: "function_call", id: call.id, name: call.name || "Unknown", args: call.args || {} }); + upsertTimelineEvent(timeline, { type: "function_call", id: call.id, name: call.name || "Unknown", args: call.args || {} }); if (matchedResponse) { if (matchedResponse.id) pairedResponseIds.add(matchedResponse.id); - timeline.push({ type: "function_response", id: matchedResponse.id, name: matchedResponse.name || "Unknown", response: matchedResponse.response || {} }); + upsertTimelineEvent(timeline, { type: "function_response", id: matchedResponse.id, name: matchedResponse.name || "Unknown", response: matchedResponse.response || {} }); } } else if (getFunctionResponse(part)) { const response = getFunctionResponse(part); if (response.id && pairedResponseIds.has(response.id)) continue; - if (!timeline.some((item) => item.type === "function_response" && item.id === response.id)) { - timeline.push({ type: "function_response", id: response.id, name: response.name || "Unknown", response: response.response || {} }); - } + upsertTimelineEvent(timeline, { type: "function_response", id: response.id, name: response.name || "Unknown", response: response.response || {} }); } else if (part.text) { - accumulatedText += part.text; - const previous = timeline.at(-1); - if (previous?.type === "text") previous.text = accumulatedText; - else timeline.push({ type: "text", text: accumulatedText }); + upsertTimelineText(timeline, part.text); } } return timeline; @@ -174,10 +176,21 @@ export function createSessionRuntime({ if (sessionId) suppressedPlanApprovalTurns.delete(sessionId); } - function renderSessionTimeline(events, stepNodes, awaitingPlanApproval = false) { - chatArea.innerHTML = ""; - stepExecutionFeed.reset(); - stepExecutionFeed.setHierarchy(stepNodes || []); + function markSessionRendered(sessionId, owner = state.activeSessionUserId || state.userId) { + renderedSessionKey = sessionRequestKey(sessionId, owner); + } + + function renderSessionTimeline(events, stepNodes, awaitingPlanApproval = false, preserveDisclosures = false) { + beginScrollTransaction(); + try { + // Running cards are initially open by default, so their state is not in + // the user-choice map yet. Snapshot the actual DOM before an in-place + // refresh changes running nodes to completed/cancelled and changes that + // default to closed. + if (preserveDisclosures) stepExecutionFeed.captureDisclosureState(); + chatArea.innerHTML = ""; + stepExecutionFeed.reset({ preserveDisclosures }); + stepExecutionFeed.setHierarchy(stepNodes || []); const sortedEvents = (events || []).map((event, index) => ({ event, timestamp: eventTimestamp(event, index), index })) .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index).map(({ event }) => event); const pendingStepNodes = (stepNodes || []).filter((node) => stepExecutionFeed.isRootStep(node)).slice() @@ -187,17 +200,29 @@ export function createSessionRuntime({ let shownPlotPaths = new Set(); let messageIndex = 0; let lastAgentTimeline = null; + let pendingAgentTimeline = []; + const flushAgentTimeline = () => { + if (!pendingAgentTimeline.length) return; + const timeline = attachStepNodes(pendingAgentTimeline, pendingStepNodes); + lastAgentTimeline = addAgentTimelineMessage(timeline, shownPlotPaths, messageIndex++); + pendingAgentTimeline = []; + }; for (const event of sortedEvents) { if (event.author === "user") { + flushAgentTimeline(); const text = displayMessageFromStoredUserText((event.content?.parts || []).map((part) => part.text || "").join("")); if (text) addMessage("user", text, messageIndex++); shownPlotPaths = new Set(); continue; } - const timeline = attachStepNodes(eventToTimelineParts(event, responsesById, pairedResponseIds), pendingStepNodes); - if (timeline.length) lastAgentTimeline = addAgentTimelineMessage(timeline, shownPlotPaths, messageIndex++); + // Persisted ADK events are often split into separate records while the + // managed SSE stream delivers them as one assistant turn. Accumulating + // adjacent non-user events gives history and live output identical + // Thinking / IN / OUT grouping, including the same upsert semantics. + eventToTimelineParts(event, responsesById, pairedResponseIds, pendingAgentTimeline); } + flushAgentTimeline(); // Preserve the assistant-message containment even if an older/incomplete // persisted event stream cannot be matched to a specific executor call. // This is particularly important while reconnecting after a session @@ -215,7 +240,13 @@ export function createSessionRuntime({ lastAgentTimeline.appendChild(fallbackHost); pendingStepNodes.forEach((node) => stepExecutionFeed.appendStatic(node, fallbackHost)); } - if (awaitingPlanApproval && lastAgentTimeline) addPlanApprovalActions(lastAgentTimeline); + if (awaitingPlanApproval && lastAgentTimeline) addPlanApprovalActions(lastAgentTimeline); + } finally { + // Loading/polling a persisted snapshot is passive. The approval card is + // visible immediately only for an already attached viewport; a reader + // elsewhere keeps the same anchor and can reach it deliberately. + endScrollTransaction(); + } } function updateSessionWorkdirDisplay(sessionData) { @@ -244,12 +275,19 @@ export function createSessionRuntime({ state.summaryGeneratedFor.add(sessionId); } const summary = sessionData.summary || state.sessionSummaries[sessionId] || ""; + const preserveDisclosures = renderedSessionKey === viewKey; + // Snapshot probing (`render: false`) is the first half of the managed + // stream reload. Remember its view so the following rendered snapshot + // is treated as an in-place refresh, including the refresh immediately + // before the Approve plan prompt appears. + renderedSessionKey = viewKey; if (render) { renderSessionBanner(summary); renderSessionTimeline( events, graphNodes, shouldShowPlanApprovalActions(sessionId, sessionData, events), + preserveDisclosures, ); } state.sessionViewCache.set(viewKey, { sessionData, events, graphNodes, files: [], summary }); @@ -412,6 +450,7 @@ export function createSessionRuntime({ return { discoverManagedRun, loadSession, + markSessionRendered, renderSessionTimeline, restorePlanApproval, startManagedRunReconnect, diff --git a/web/vite-frontend/src/features/ui/disclosureState.js b/web/vite-frontend/src/features/ui/disclosureState.js new file mode 100644 index 00000000..a15836c6 --- /dev/null +++ b/web/vite-frontend/src/features/ui/disclosureState.js @@ -0,0 +1,83 @@ +/** + * Keeps native
state and viewport behavior consistent across + * frequently re-rendered views. + */ +export function createDisclosureController({ + captureScrollPosition, + restoreScrollPosition, +} = {}) { + const openState = new Map(); + + function wire(details, key, { defaultOpen = false, onToggle } = {}) { + if (!details || !key) return details; + + details.dataset.disclosureKey = key; + details.querySelectorAll("p, pre, blockquote, li, table, h1, h2, h3, h4, h5, h6").forEach((block, index) => { + block.dataset.readingAnchor = `${key}:block:${index}`; + }); + details.open = openState.has(key) ? openState.get(key) : Boolean(defaultOpen); + + let pendingViewport = null; + const captureViewport = () => { + // Use this disclosure as the reading anchor. At the bottom the renderer + // returns an explicit follow-bottom snapshot; elsewhere the Node stays + // at the same viewport offset while its body expands below it. + pendingViewport = captureScrollPosition?.(details, { + force: true, + detachBottom: true, + }); + }; + const isSummaryEvent = (event) => { + const target = event.target; + const summary = target?.closest?.("summary"); + const control = target?.closest?.("button, a, input, select, textarea"); + return summary?.parentElement === details && !control; + }; + details.addEventListener("click", (event) => { + if (isSummaryEvent(event)) captureViewport(); + }); + details.addEventListener("keydown", (event) => { + if (!isSummaryEvent(event)) return; + if (event.key === "Enter" || event.key === " ") captureViewport(); + }); + + details.addEventListener("toggle", (event) => { + if (event.target !== details) return; + const viewport = pendingViewport; + pendingViewport = null; + // Assigning `open` while rebuilding the view also emits `toggle`. + // Only a toggle preceded by interaction is a durable user choice. + if (!viewport) return; + openState.set(key, details.open); + onToggle?.(details.open); + restoreScrollPosition?.(viewport); + }); + return details; + } + + function prune(liveKeys) { + for (const key of openState.keys()) { + if (!liveKeys.has(key)) openState.delete(key); + } + } + + function prunePrefix(prefix, liveKeys) { + for (const key of openState.keys()) { + if (key.startsWith(prefix) && !liveKeys.has(key)) openState.delete(key); + } + } + + function deletePrefix(prefix) { + for (const key of openState.keys()) { + if (key.startsWith(prefix)) openState.delete(key); + } + } + + function capture(root) { + root?.querySelectorAll?.("details[data-disclosure-key]").forEach((details) => { + openState.set(details.dataset.disclosureKey, details.open); + }); + } + + return { capture, clear: () => openState.clear(), deletePrefix, prune, prunePrefix, state: openState, wire }; +} diff --git a/web/vite-frontend/src/main.js b/web/vite-frontend/src/main.js index d25fabc1..04f0f519 100644 --- a/web/vite-frontend/src/main.js +++ b/web/vite-frontend/src/main.js @@ -10,6 +10,8 @@ import { AgentGraphView, StepExecutionFeed } from "./features/graphs/AgentGraphV import { ExecutionPlanView } from "./features/graphs/ExecutionPlanView.js"; import { createSkillGraphController } from "./features/skills/SkillGraphController.js"; import { createSettingsController } from "./features/settings/SettingsController.js"; +import { mountOrbitalAgentIndicator } from "./components/mountOrbitalAgentIndicator.js"; +import { createDisclosureController } from "./features/ui/disclosureState.js"; import "./styles/index.css"; // --------------------------------------------------------------------------- @@ -67,7 +69,11 @@ const state = { const chatArea = document.getElementById("chat-area"); const textInput = document.getElementById("text-input"); +const inputArea = document.querySelector(".input-area"); const inputContainer = document.querySelector(".input-container"); +const agentRunningIndicator = document.getElementById("agent-running-indicator"); +const agentRunningOrbital = document.getElementById("agent-running-orbital"); +const agentRunningText = document.getElementById("agent-running-text"); const sendBtn = document.getElementById("send-btn"); const fileUploadBtn = document.getElementById("file-upload-btn"); const fileUploadInput = document.getElementById("file-upload-input"); @@ -162,15 +168,25 @@ const { addMessage, appendLiveTurnChild, applyUserAvatarToEl, + beginScrollTransaction, captureScrollPosition, createAgentAvatarEl, createJsonBlock, - isChatNearBottom, + endScrollTransaction, + markReadingAnchors, + protectAsyncContentLayout, renderMarkdown, restoreScrollPosition, scrollToBottom, setUserAvatar, -} = createChatRenderer({ chatArea }); + updatePreservingReadingPosition, +} = createChatRenderer({ chatArea, bottomOverlay: inputArea }); + +const createChatDisclosureController = () => createDisclosureController({ + captureScrollPosition, + restoreScrollPosition, +}); +const chatDisclosureController = createChatDisclosureController(); const settingsController = createSettingsController({ state, applyLogin }); @@ -980,10 +996,7 @@ document.getElementById("evaluation-template-delete")?.addEventListener("click", const stepExecutionFeed = new StepExecutionFeed({ chatArea, isSending: () => Boolean(activeSessionRequest()), - isChatNearBottom, - captureScrollPosition, - restoreScrollPosition, - scrollToBottom, + updatePreservingReadingPosition, createAgentAvatarEl, stepFeedTitle, formatStepDuration, @@ -992,6 +1005,7 @@ const stepExecutionFeed = new StepExecutionFeed({ renderStepToolCall, requestStepCancellation, createArtifactListItem, + disclosureController: chatDisclosureController, }); const agentGraph = new AgentGraphView("agent-graph", { stepExecutionFeed, @@ -1066,8 +1080,27 @@ function releaseSessionRequest(request) { } } +const orbitalIndicator = mountOrbitalAgentIndicator(agentRunningOrbital); + +function updateAgentRunningStatus(phase = "working") { + const phases = { + working: ["MatCreator is working. Please wait…", "thinking"], + thinking: ["MatCreator is thinking…", "thinking"], + planning: ["MatCreator is planning the workflow…", "thinking"], + searching: ["MatCreator is searching for information…", "searching"], + executing: ["MatCreator is executing the workflow…", "computing"], + computing: ["MatCreator is computing…", "computing"], + }; + const [label, orbitalState] = phases[phase] || phases.working; + if (agentRunningText) agentRunningText.textContent = label; + orbitalIndicator?.render(orbitalState); +} + function updateSendButtonState() { const running = Boolean(activeSessionRequest()); + inputArea?.classList.toggle("is-agent-running", running); + if (agentRunningIndicator) agentRunningIndicator.setAttribute("aria-hidden", String(!running)); + if (!running) updateAgentRunningStatus(); if (!sendBtn) return; sendBtn.textContent = running ? "■" : "➜"; sendBtn.title = running ? "Stop" : "Send"; @@ -2568,12 +2601,19 @@ function createTimelineImage(path) { img.hidden = true; img.style.cursor = "zoom-in"; img.addEventListener("load", () => { - loading.remove(); - img.hidden = false; + // Image decode changes layout asynchronously, outside the synchronous + // timeline update. Capture immediately before the DOM height changes so + // the anchor cannot be stale if other streamed events arrived meanwhile. + updatePreservingReadingPosition(() => { + loading.remove(); + img.hidden = false; + }); }); img.addEventListener("error", () => { - img.remove(); - loading.replaceWith(createImageLoadFallback(path)); + updatePreservingReadingPosition(() => { + img.remove(); + loading.replaceWith(createImageLoadFallback(path)); + }); }, { once: true }); img.addEventListener("click", () => lightbox.open(img.src)); img.src = pathToApiUrl(path); @@ -2590,12 +2630,34 @@ function isExecutorLauncherTool(name) { // collapsible
blocks; text parts render as markdown; // plot_path responses render as inline images. function renderTimeline(container, timeline, shownPlotPaths = null) { - const shouldStick = isChatNearBottom(); - const scrollPosition = shouldStick ? null : captureScrollPosition(); - container.innerHTML = ""; - const containerPlotPaths = container._plotPaths || new Set(); - const visiblePlotPaths = new Set(); - for (const item of timeline) { + const disclosures = chatDisclosureController; + const agentMessage = container.closest(".agent-message:not(.step-feed-message)"); + const agentMessages = [...chatArea.children] + .filter((element) => element.matches?.(".agent-message:not(.step-feed-message)")); + const agentIndex = agentMessages.indexOf(agentMessage); + // The live message has no persisted msgIndex yet. Its assistant-message + // ordinal remains stable when the Approve plan prompt causes a snapshot + // rebuild, so use that as the cross-render scope. + const messageKey = agentIndex >= 0 + ? `agent:${agentIndex}` + : `message:${agentMessage?.dataset.msgIndex || "live"}`; + const disclosurePrefix = `timeline:${messageKey}:`; + const liveKeys = new Set(); + const wireTimelineDetails = (details, key, defaultOpen = false) => { + const scopedKey = `${disclosurePrefix}${key}`; + liveKeys.add(scopedKey); + disclosures.wire(details, scopedKey, { defaultOpen }); + return scopedKey; + }; + updatePreservingReadingPosition(() => { + // Timeline updates rebuild Thinking/IN/OUT and any inline Node cards. + // Persist their actual DOM state first; defaults alone are insufficient + // once a running Node has become completed but remains visibly open. + disclosures.capture(chatArea); + container.innerHTML = ""; + const containerPlotPaths = container._plotPaths || new Set(); + const visiblePlotPaths = new Set(); + for (const item of timeline) { if (item.type === "thought") { const details = document.createElement("details"); details.className = "timeline-thought"; @@ -2605,16 +2667,20 @@ function renderTimeline(container, timeline, shownPlotPaths = null) { const body = document.createElement("div"); body.className = "markdown-content"; body.innerHTML = renderMarkdown(item.text || ""); + const thoughtKey = item.timelineId || `thought:${item.text || ""}`; + markReadingAnchors(body, `${disclosurePrefix}${thoughtKey}:content`); + protectAsyncContentLayout(body); details.appendChild(body); + wireTimelineDetails(details, thoughtKey); container.appendChild(details); } else if (item.type === "function_call") { const details = document.createElement("details"); details.className = "timeline-function-call"; - if (isExecutorLauncherTool(item.name)) details.open = true; const summary = document.createElement("summary"); summary.innerHTML = `IN ${item.name}`; details.appendChild(summary); details.appendChild(createJsonBlock(JSON.stringify(item.args, null, 2))); + wireTimelineDetails(details, item.timelineId || `function-call:${item.id || item.name || "Unknown"}`, isExecutorLauncherTool(item.name)); container.appendChild(details); if (isExecutorLauncherTool(item.name)) { const inlineHost = document.createElement("div"); @@ -2634,6 +2700,7 @@ function renderTimeline(container, timeline, shownPlotPaths = null) { summary.innerHTML = `OUT ${item.name}`; details.appendChild(summary); details.appendChild(createJsonBlock(JSON.stringify(item.response, null, 2))); + wireTimelineDetails(details, item.timelineId || `function-response:${item.id || item.name || "Unknown"}`); container.appendChild(details); for (const plotPath of getPlotPaths(item.response)) { if ( @@ -2652,13 +2719,15 @@ function renderTimeline(container, timeline, shownPlotPaths = null) { const div = document.createElement("div"); div.className = "markdown-content"; div.innerHTML = renderMarkdown(item.text || ""); + markReadingAnchors(div, `${disclosurePrefix}${item.timelineId || "text:legacy"}:content`); + protectAsyncContentLayout(div); container.appendChild(div); } - } - container._plotPaths = visiblePlotPaths; - visiblePlotPaths.forEach((path) => shownPlotPaths?.add(path)); - if (shouldStick) scrollToBottom({ preserveUserPosition: true }); - else restoreScrollPosition(scrollPosition); + } + disclosures.prunePrefix(disclosurePrefix, liveKeys); + container._plotPaths = visiblePlotPaths; + visiblePlotPaths.forEach((path) => shownPlotPaths?.add(path)); + }); } // Create an agent message div with an inner timeline container, append to @@ -2757,10 +2826,10 @@ function addPlanApprovalActions(timelineContainer) { bubble.append(prompt, actions, feedback); responseMessage.appendChild(bubble); agentMessage.after(responseMessage); - // A plan approval is an explicit request for input, so always reveal it. - requestAnimationFrame(() => { - requestAnimationFrame(() => responseMessage.scrollIntoView({ block: "center" })); - }); + // Approval prompts follow the same bottom placement as every other newly + // appended dialog. The shared reserve keeps the full prompt above the + // floating composer, regardless of composer/upload height. + scrollToBottom({ preserveUserPosition: true }); } function formatStepDuration(node) { @@ -2862,6 +2931,8 @@ const sessionRuntime = createSessionRuntime({ addMessage, addAgentTimelineMessage, addPlanApprovalActions, + beginScrollTransaction, + endScrollTransaction, renderSessionBanner, renderSessionFilesTree, refreshSessionFiles, @@ -2892,6 +2963,7 @@ const messageStreamController = createMessageStreamController({ agentGraph, planGraph, updateSendButtonState, + updateAgentRunningStatus, releaseSessionRequest, managedRunEventsUrl, shouldRefreshPlanGraphForTool, @@ -3357,6 +3429,7 @@ function renderSessionSnapshot(snapshot) { if (!snapshot) return; renderSessionBanner(snapshot.summary || ""); sessionRuntime.renderSessionTimeline(snapshot.events || [], snapshot.graphNodes || []); + sessionRuntime.markSessionRendered(state.sessionId, state.activeSessionUserId || state.userId); renderSessionFilesTree(snapshot.files || []); sessionRuntime.updateSessionWorkdirDisplay(snapshot.sessionData || {}); } diff --git a/web/vite-frontend/src/styles/chat.css b/web/vite-frontend/src/styles/chat.css index 9b1ce890..55201edd 100644 --- a/web/vite-frontend/src/styles/chat.css +++ b/web/vite-frontend/src/styles/chat.css @@ -700,8 +700,10 @@ .chat-area { flex: 1; min-height: 0; - padding: 16px 20px 130px; + padding: 16px 20px var(--chat-bottom-reserve, 130px); + scroll-padding-bottom: var(--chat-bottom-reserve, 130px); overflow-y: auto; + overflow-anchor: none; display: flex; flex-direction: column; position: relative; @@ -903,6 +905,29 @@ background: rgba(255, 255, 255, 0.03); } +.step-feed-live-response { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 10px; + border-left: 2px solid #fbbf24; + border-radius: 0 8px 8px 0; + background: rgba(251, 191, 36, 0.08); + color: var(--text); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.step-feed-live-response > span:first-child { + color: #fbbf24; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .step-feed-section { display: flex; flex-direction: column; @@ -991,16 +1016,34 @@ details.step-feed-nested { display: flex; align-items: center; justify-content: center; } -.markdown-content { padding: 12px 14px; border-radius: 12px; line-height: 1.6; font-size: 14px; } +.markdown-content { + min-width: 0; + max-width: 100%; + padding: 12px 14px; + border-radius: 12px; + line-height: 1.6; + font-size: 14px; + overflow-wrap: anywhere; +} .markdown-content a { color: var(--accent); text-decoration: none; font-weight: 600; } -.markdown-content table { - width: 100%; - border-collapse: collapse; +.markdown-content .markdown-table-scroll { + max-width: 100%; margin: 8px 0; - font-size: 13px; + overflow-x: auto; + overscroll-behavior-inline: contain; border: 1px solid var(--border); border-radius: 8px; - overflow: hidden; +} +.markdown-content .markdown-table-scroll:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.markdown-content table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + margin: 0; + font-size: 13px; } .markdown-content thead { background: rgba(125, 211, 252, 0.08); @@ -1330,6 +1373,28 @@ body[data-theme="dark"] .input-container[data-agent-mode="bench"] { transform: translateY(2px); } +.agent-running-indicator { + display: none; + align-items: center; + gap: 8px; + margin: 0 4px 6px; + color: var(--accent); + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.input-area.is-agent-running .agent-running-indicator { + display: flex; +} + +.agent-running-orbital { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 18px; +} + .plan-approval-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));