= ({ label }) => {
9 | // TODO: reimplement error handling - from OperatorVal (status bucket)
10 | return (
11 |
12 | {label}
13 | {/* {operatorEvent?.type === OperatorEventType.error && (
14 |
18 |
19 |
20 | )} */}
21 |
22 | )
23 | }
24 |
25 | export default OperatorHeader
26 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/statusdot.tsx:
--------------------------------------------------------------------------------
1 | import { Badge, Tooltip } from "@mui/material"
2 | import type { AgentStatus } from "../types/gen"
3 | import { getAgentStatusColor } from "../utils/statusColor"
4 |
5 | type StatusDotProps = {
6 | status: AgentStatus
7 | tooltipContent?: React.ReactNode
8 | }
9 |
10 | export const StatusDot = ({ status, tooltipContent }: StatusDotProps) => {
11 | const color = getAgentStatusColor(status)
12 |
13 | const badgeElement =
14 |
15 | if (tooltipContent) {
16 | return (
17 |
18 | {badgeElement}
19 |
20 | )
21 | }
22 |
23 | return badgeElement
24 | }
25 |
--------------------------------------------------------------------------------
/operators/distiller-streaming/Containerfile:
--------------------------------------------------------------------------------
1 | FROM interactem-operator
2 |
3 | WORKDIR /app
4 | COPY ./pyproject.toml ./poetry.lock ./README.md /app/
5 |
6 | # Base image installs interactem-core at /interactem/core.
7 | # Locally, the project uses ../../backend/core which
8 | # resolves to /backend/core inside the container. We symlink it here
9 | # so poetry can find it.
10 | RUN mkdir -p /backend && \
11 | if [ -d /interactem/core ]; then \
12 | ln -sfn /interactem/core /backend/core; \
13 | fi && \
14 | if [ -d /interactem/operators ]; then \
15 | ln -sfn /interactem/operators /backend/operators; \
16 | fi
17 |
18 | RUN poetry install --no-root --without dev
19 |
20 | COPY ./distiller_streaming/ /app/distiller_streaming/
21 | RUN poetry install --only-root
--------------------------------------------------------------------------------
/backend/core/interactem/core/config.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 | from enum import Enum
3 |
4 | from pydantic_settings import BaseSettings, SettingsConfigDict
5 |
6 |
7 | class LogLevel(str, Enum):
8 | INFO = "INFO"
9 | DEBUG = "DEBUG"
10 | WARNING = "WARNING"
11 | ERROR = "ERROR"
12 | CRITICAL = "CRITICAL"
13 |
14 |
15 | class Settings(BaseSettings):
16 | model_config = SettingsConfigDict(env_file=".env", extra="ignore")
17 | CORE_PACKAGE_DIR: pathlib.Path = pathlib.Path(__file__).parent.parent
18 | OPERATORS_PACKAGE_DIR: pathlib.Path = (
19 | pathlib.Path(__file__).parent.parent.parent.parent / "operators" / "interactem"
20 | )
21 | LOG_LEVEL: LogLevel = LogLevel.INFO
22 | PARALLEL_EXPANSION_FACTOR: int = 4
23 |
24 |
25 | cfg = Settings()
26 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/config/index.ts:
--------------------------------------------------------------------------------
1 | interface Config {
2 | NATS_SERVER_URL: string
3 | API_BASE_URL: string
4 | }
5 |
6 | function buildConfig(): Config {
7 | // Use .env.development variables in development
8 | if (import.meta.env.DEV) {
9 | return {
10 | NATS_SERVER_URL: import.meta.env.VITE_NATS_SERVER_URL || "",
11 | API_BASE_URL: import.meta.env.VITE_REACT_APP_API_BASE_URL || "",
12 | }
13 | }
14 |
15 | const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
16 | const host = window.location.host
17 |
18 | return {
19 | NATS_SERVER_URL: `${protocol}//${host}/nats`,
20 | API_BASE_URL: `${window.location.protocol}//${host}/`,
21 | }
22 | }
23 |
24 | const config: Config = buildConfig()
25 |
26 | export default config
27 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/hooks/api/usePipelineQuery.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query"
2 | import { pipelinesReadPipelineOptions } from "../../client/generated/@tanstack/react-query.gen"
3 |
4 | export const usePipeline = (pipelineId: string) => {
5 | return useQuery({
6 | ...pipelinesReadPipelineOptions({
7 | path: { id: pipelineId },
8 | }),
9 | })
10 | }
11 |
12 | export const usePipelineName = (pipelineId: string) => {
13 | const { data: pipeline, isFetching } = usePipeline(pipelineId)
14 |
15 | if (isFetching) {
16 | return "Loading..."
17 | }
18 |
19 | // Return first 8 of uuid if no name available
20 | if (!pipeline || !pipeline.name) {
21 | return `${pipelineId.substring(0, 8)}`
22 | }
23 |
24 | return pipeline.name
25 | }
26 |
--------------------------------------------------------------------------------
/backend/operators/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-operators"
3 | version = "0.1.0"
4 | description = ""
5 | authors = [{name = "Sam Welborn", email = "swelborn@lbl.gov"}]
6 | readme = "README.md"
7 | requires-python = ">=3.10"
8 | dependencies = [
9 | "interactem-core",
10 | "pyzmq>=27.1.0,<28",
11 | "aiohttp>=3.10.5,<4",
12 | ]
13 |
14 | [tool.uv.sources]
15 | interactem-core = { path = "../core", editable = true }
16 |
17 | [tool.poetry]
18 | packages = [{ include = "interactem" }]
19 |
20 | [tool.poetry.dependencies]
21 | interactem-core = {path = "../core", develop = true}
22 |
23 |
24 | [build-system]
25 | requires = ["poetry-core"]
26 | build-backend = "poetry.core.masonry.api"
27 |
28 | [tool.ruff]
29 | target-version = "py310"
30 | extend = "../../.ruff.toml"
--------------------------------------------------------------------------------
/backend/rdma/src/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | # RDMA Proxy core library
2 | add_library(rdma_proxy_core SHARED
3 | # RdmaProxyService.cpp
4 | # op_receiver.cpp
5 | # op_sender.cpp
6 | prx_transporter.cpp
7 | )
8 |
9 | # Include directories for the core library
10 | target_include_directories(rdma_proxy_core PUBLIC
11 | ${CMAKE_CURRENT_SOURCE_DIR}/../include
12 | ${CMAKE_CURRENT_SOURCE_DIR}/../common/include
13 | ${CMAKE_CURRENT_SOURCE_DIR}/../libs/thallium/include
14 | ${CMAKE_CURRENT_SOURCE_DIR}/../libs/argobots/include
15 | )
16 |
17 | # Link dependencies
18 | target_link_libraries(rdma_proxy_core PUBLIC
19 | common_utils
20 | rdma_external_libs
21 | thallium
22 | )
23 |
24 | # Set compile features
25 | target_compile_features(rdma_proxy_core PUBLIC cxx_std_17)
26 |
--------------------------------------------------------------------------------
/operators/distiller-state-client/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "ffc731fc-2a95-46d8-b513-a5efd47701d4",
3 | "image": "ghcr.io/nersc/interactem/distiller-state-client:latest",
4 | "label": "Distiller Pipeline State",
5 | "description": "Connect to the distiller pipeline state server and display the state of the pipeline.",
6 | "outputs": [
7 | {
8 | "name": "out",
9 | "label": "The output",
10 | "type": "table",
11 | "description": "This table to display"
12 | }
13 | ],
14 | "parameters": [
15 | {
16 | "name": "pub_address",
17 | "label": "Publisher Address",
18 | "type": "str",
19 | "default": "tcp://localhost:7082",
20 | "description": "This is the address to get the state from",
21 | "required": true
22 | }
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/operators/error/run.py:
--------------------------------------------------------------------------------
1 | import random
2 | from typing import Any
3 |
4 | from interactem.core.logger import get_logger
5 | from interactem.core.models.messages import BytesMessage, MessageHeader, MessageSubject
6 | from interactem.operators.operator import operator
7 |
8 | logger = get_logger()
9 |
10 |
11 | @operator
12 | def error(inputs: BytesMessage | None, parameters: dict[str, Any]) -> BytesMessage:
13 | raise_exception = random.choice([True, False])
14 | # raise_exception = True
15 | logger.info(f"Error state: { raise_exception }")
16 | if raise_exception:
17 | raise Exception("This is an error")
18 | else:
19 | return BytesMessage(
20 | header=MessageHeader(subject=MessageSubject.BYTES, meta={}),
21 | data=b"Hello, World!",
22 | )
23 |
--------------------------------------------------------------------------------
/.github/workflows/version-check.yml:
--------------------------------------------------------------------------------
1 | name: Version Guard
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 | workflow_dispatch:
9 |
10 | concurrency:
11 | group: ${{ github.workflow }}-${{ github.ref }}
12 | cancel-in-progress: true
13 |
14 | permissions:
15 | contents: read
16 |
17 | jobs:
18 | version-guard:
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Install uv
25 | uses: astral-sh/setup-uv@v7
26 |
27 | - name: Set up Python
28 | run: uv python install
29 |
30 | - name: bump-my-version dry run (check only)
31 | run: uvx bump-my-version@latest bump patch --dry-run --config-file ./pyproject.toml --allow-dirty
32 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/hooks/nats/useImage.ts:
--------------------------------------------------------------------------------
1 | import { STREAM_IMAGES } from "../../constants/nats"
2 | import { useOperatorInSelectedPipeline } from "./useOperatorStatus"
3 | import { useStreamMessage } from "./useStreamMessage"
4 |
5 | export const useImage = (operatorID: string): Uint8Array | null => {
6 | const subject = `${STREAM_IMAGES}.${operatorID}`
7 | const { isInRunningPipeline } = useOperatorInSelectedPipeline(operatorID)
8 |
9 | const { data } = useStreamMessage({
10 | streamName: STREAM_IMAGES,
11 | subject,
12 | enabled: isInRunningPipeline,
13 | transform: (_, originalMessage) => {
14 | // Return the raw binary data instead of parsing as JSON
15 | return originalMessage.data
16 | },
17 | })
18 |
19 | return isInRunningPipeline ? data : null
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/interactEM/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.6.1/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "files": {
7 | "ignore": ["node_modules", "dist"]
8 | },
9 | "linter": {
10 | "enabled": true,
11 | "rules": {
12 | "recommended": true,
13 | "correctness": {
14 | "noUnusedImports": {
15 | "level": "error"
16 | }
17 | },
18 | "suspicious": {
19 | "noExplicitAny": "off",
20 | "noArrayIndexKey": "off"
21 | },
22 | "style": {
23 | "noNonNullAssertion": "off"
24 | }
25 | }
26 | },
27 | "formatter": {
28 | "indentStyle": "space"
29 | },
30 | "javascript": {
31 | "formatter": {
32 | "quoteStyle": "double",
33 | "semicolons": "asNeeded"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests/utils/utils.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 | from fastapi.testclient import TestClient
5 |
6 | from interactem.app.core.config import settings
7 |
8 |
9 | def random_lower_string() -> str:
10 | return "".join(random.choices(string.ascii_lowercase, k=32))
11 |
12 |
13 | def random_username() -> str:
14 | return f"user_{random_lower_string()[:8]}"
15 |
16 |
17 | def get_superuser_token_headers(client: TestClient) -> dict[str, str]:
18 | login_data = {
19 | "username": settings.FIRST_SUPERUSER_USERNAME,
20 | "password": settings.FIRST_SUPERUSER_PASSWORD,
21 | }
22 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
23 | tokens = r.json()
24 | a_token = tokens["access_token"]
25 | headers = {"Authorization": f"Bearer {a_token}"}
26 | return headers
27 |
--------------------------------------------------------------------------------
/backend/rdma/common/include/abt_type.hpp:
--------------------------------------------------------------------------------
1 | /**
2 | * @file abt_type.hpp
3 | * @brief Argobots-related type definitions for threading and scheduling
4 | *
5 | * Contains type definitions for Argobots lightweight threading support,
6 | * including schedule types (FIFO, ROUND_ROBIN, PRIORITY) and pool types
7 | * (FAST, SLOW, HIGH_PRIORITY) for concurrent operation management.
8 | */
9 |
10 | #pragma once
11 |
12 | #include
13 | #include
14 | #include
15 | #include
16 |
17 | namespace interactEM {
18 |
19 | enum class ScheduleType {
20 | SCHEDULE_TYPE_DEFAULT,
21 | SCHEDULE_TYPE_FIFO,
22 | SCHEDULE_TYPE_ROUND_ROBIN,
23 | SCHEDULE_TYPE_PRIORITY
24 | };
25 |
26 | enum class PoolType {
27 | POOL_TYPE_DEFAULT,
28 | POOL_TYPE_FAST,
29 | POOL_TYPE_SLOW,
30 | POOL_TYPE_HIGH_PRIORITY
31 | };
32 |
33 | };
--------------------------------------------------------------------------------
/operators/center-of-mass-reduce/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "4e4abf43-0464-42b7-8020-b6acea28a6f6",
3 | "image": "ghcr.io/nersc/interactem/center-of-mass-reduce",
4 | "label": "Reduce Center of Mass",
5 | "description": "Reduces partial centers of mass for a particular scan",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "frame",
11 | "description": "inputs frame"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "com",
17 | "label": "Output com",
18 | "type": "array",
19 | "description": "Center of mass"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "emit_every",
25 | "type": "int",
26 | "description": "Emit every N frames",
27 | "default": "50",
28 | "label": "Emit Every",
29 | "required": true
30 | }
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/cli/interactem/cli/settings.py:
--------------------------------------------------------------------------------
1 | import typer
2 | from pydantic import ValidationError
3 | from pydantic_settings import BaseSettings, SettingsConfigDict
4 | from rich import print
5 |
6 |
7 | class Settings(BaseSettings):
8 | interactem_username: str
9 | interactem_password: str
10 | api_base_url: str = "http://localhost:8080/api/v1"
11 |
12 | model_config = SettingsConfigDict(
13 | env_file=".env", env_file_encoding="utf-8", case_sensitive=False
14 | )
15 |
16 |
17 | def get_settings() -> Settings:
18 | try:
19 | return Settings() # type: ignore[call-arg]
20 | except ValidationError as e:
21 | print("[red]Configuration error:[/red]")
22 | for error in e.errors():
23 | field = error["loc"][0]
24 | msg = error["msg"]
25 | print(f"[red] - {field}: {msg}[/red]")
26 | raise typer.Exit(1)
27 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests/api/routes/test.http:
--------------------------------------------------------------------------------
1 | ### Get an access token
2 | POST http://localhost:8080/api/v1/login/access-token
3 | Content-Type: application/x-www-form-urlencoded
4 |
5 | username=admin@example.com&password=changethis
6 |
7 | ### Get an access token from external account
8 | POST http://localhost:8080/api/v1/login/external-token
9 | Content-Type: application/json
10 | Authorization: bearer
11 |
12 | ### Create an agent
13 | POST http://localhost:8080/api/v1/agents/launch
14 | Content-Type: application/json
15 | Authorization: bearer put_the_access_token_here
16 |
17 | {
18 | "machine": "perlmutter",
19 | "num_nodes": 1,
20 | "qos": "debug",
21 | "constraint": "cpu",
22 | "walltime": "00:00:01",
23 | "account": "PUT_AN_ACCOUNT_HERE"
24 | }
25 |
26 | ###
27 | GET http://localhost:80/api/v1/pipelines
28 | Authorization: bearer put_the_access_token_here
--------------------------------------------------------------------------------
/frontend/interactEM/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | // Recommended setting (not in strict, but should be)
23 | "noUncheckedIndexedAccess": true,
24 | "types": ["node"]
25 | },
26 | "include": ["src", "tests", "playwright.config.ts"],
27 | "references": [{ "path": "./tsconfig.node.json" }]
28 | }
29 |
--------------------------------------------------------------------------------
/.devcontainer/orchestrator-container/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile
3 |
4 | {
5 | "name": "Orchestrator Container",
6 | "dockerComposeFile": ["../../docker-compose.yml", "../../docker-compose.override.yml"],
7 | "service": "orchestrator",
8 | "shutdownAction": "none",
9 | "workspaceFolder": "/app",
10 | "features": {
11 | "ghcr.io/devcontainers/features/git:1": {}
12 | },
13 | "customizations": {
14 | "vscode": {
15 | "extensions": [
16 | "charliermarsh.ruff",
17 | "ms-azuretools.vscode-docker",
18 | "ms-python.debugpy",
19 | "ms-python.python",
20 | "johnpapa.vscode-peacock"
21 | ]
22 | }
23 | }
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/d1149e7679f9_add_is_external_to_user.py:
--------------------------------------------------------------------------------
1 | """Add is_external to User
2 |
3 | Revision ID: d1149e7679f9
4 | Revises: 516457e800b5
5 | Create Date: 2025-01-15 15:09:21.905064
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = 'd1149e7679f9'
15 | down_revision = '516457e800b5'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade():
21 | # Add column with default value as false (not using autogenerated)
22 | op.add_column('user', sa.Column('is_external', sa.Boolean(), server_default="f", nullable=False))
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_column('user', 'is_external')
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/backend/core/interactem/core/models/logs.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from enum import Enum
3 |
4 | from pydantic import BaseModel, ConfigDict
5 |
6 | from .runtime import IdType, RuntimeOperatorID
7 |
8 |
9 | class LogType(str, Enum):
10 | AGENT = "agent"
11 | OPERATOR = "operator"
12 | VECTOR = "vector"
13 |
14 |
15 | class AgentLog(BaseModel):
16 | model_config: ConfigDict = ConfigDict(extra="ignore")
17 | agent_id: IdType
18 | host: str
19 | log_type: LogType
20 | level: str
21 | log: str
22 | module: str
23 | timestamp: datetime
24 |
25 |
26 | class OperatorLog(BaseModel):
27 | model_config: ConfigDict = ConfigDict(extra="ignore")
28 | agent_id: IdType
29 | deployment_id: IdType
30 | operator_id: RuntimeOperatorID
31 | host: str
32 | level: str
33 | log: str
34 | log_type: LogType
35 | module: str
36 | timestamp: datetime
37 |
--------------------------------------------------------------------------------
/backend/launcher/tests/expected_script.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # ██╗███╗ ██╗████████╗███████╗██████╗ █████╗ ██████╗████████╗███████╗███╗ ███╗
4 | # ██║████╗ ██║╚══██╔══╝██╔════╝██╔══██╗██╔══██╗██╔════╝╚══██╔══╝██╔════╝████╗ ████║
5 | # ██║██╔██╗ ██║ ██║ █████╗ ██████╔╝███████║██║ ██║ █████╗ ██╔████╔██║
6 | # ██║██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗██╔══██║██║ ██║ ██╔══╝ ██║╚██╔╝██║
7 | # ██║██║ ╚████║ ██║ ███████╗██║ ██║██║ ██║╚██████╗ ██║ ███████╗██║ ╚═╝ ██║
8 | # ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
9 |
10 | #SBATCH --qos=normal
11 | #SBATCH --constraint=gpu
12 | #SBATCH --time=01:30:00
13 | #SBATCH --account=test_account
14 | #SBATCH --nodes=2
15 | #SBATCH --exclusive
16 |
17 | export HDF5_USE_FILE_LOCKING=FALSE
18 | cd /path/to/.env
19 | srun --nodes=2 --ntasks-per-node=1 uv run --project /path/to/interactEM/backend/agent interactem-agent
--------------------------------------------------------------------------------
/backend/app/interactem/app/core/security.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta, timezone
2 | from typing import Any
3 |
4 | import jwt
5 | from passlib.context import CryptContext
6 |
7 | from interactem.app.core.config import settings
8 |
9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
10 |
11 |
12 | ALGORITHM = "HS256"
13 |
14 |
15 | def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
16 | expire = datetime.now(timezone.utc) + expires_delta
17 | to_encode = {"exp": expire, "sub": str(subject)}
18 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
19 | return encoded_jwt
20 |
21 |
22 | def verify_password(plain_password: str, hashed_password: str) -> bool:
23 | return pwd_context.verify(plain_password, hashed_password)
24 |
25 |
26 | def get_password_hash(password: str) -> str:
27 | return pwd_context.hash(password)
28 |
--------------------------------------------------------------------------------
/.ruff.toml:
--------------------------------------------------------------------------------
1 | target-version = "py310"
2 | exclude = [
3 | "backend/app/interactem/app/alembic/**",
4 | "backend/agent/thirdparty/**",
5 | "conftest.py",
6 | "tests/**"
7 | ]
8 |
9 | [lint]
10 | exclude = ["**/__init__.py"]
11 | select = [
12 | "E", # pycodestyle errors
13 | "W", # pycodestyle warnings
14 | "F", # pyflakes
15 | "I", # isort
16 | "B", # flake8-bugbear
17 | "C4", # flake8-comprehensions
18 | "UP", # pyupgrade
19 | ]
20 | ignore = [
21 | "E501", # line too long, handled by black
22 | "B008", # do not perform function calls in argument defaults
23 | "W191", # indentation contains tabs
24 | "B904", # Allow raising exceptions without from e, for HTTPException
25 | ]
26 |
27 | [lint.pyupgrade]
28 | # Preserve types, even if a file imports `from __future__ import annotations`.
29 | keep-runtime-typing = true
30 |
31 | [lint.isort]
32 | known-first-party = ["interactem"]
--------------------------------------------------------------------------------
/.devcontainer/backend-app-container/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile
3 |
4 | {
5 | "name": "Backend App Container",
6 | "dockerComposeFile": ["../../docker-compose.yml", "../../docker-compose.override.yml"],
7 | "service": "backend",
8 | "shutdownAction": "none",
9 | "workspaceFolder": "/app",
10 | "features": {
11 | "ghcr.io/devcontainers/features/git:1": {}
12 | },
13 | "customizations": {
14 | "vscode": {
15 | "extensions": [
16 | "charliermarsh.ruff",
17 | "ms-azuretools.vscode-docker",
18 | "ms-python.debugpy",
19 | "ms-python.python",
20 | "humao.rest-client",
21 | "johnpapa.vscode-peacock"
22 | ]
23 | }
24 | }
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/.devcontainer/operator-container/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile
3 |
4 | {
5 | "name": "Operator Container",
6 | "dockerComposeFile": ["../../docker-compose.yml", "../../docker-compose.override.yml", "../../docker-compose.operator.yml"],
7 | "service": "operator",
8 | "shutdownAction": "none",
9 | "workspaceFolder": "/app",
10 | "features": {
11 | "ghcr.io/devcontainers/features/git:1": {}
12 | },
13 | "customizations": {
14 | "vscode": {
15 | "extensions": [
16 | "charliermarsh.ruff",
17 | "ms-azuretools.vscode-docker",
18 | "ms-python.debugpy",
19 | "ms-python.python",
20 | "humao.rest-client",
21 | "johnpapa.vscode-peacock"
22 | ]
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/b76f7b29a6e7_add_operator_positions_to_pipeline_.py:
--------------------------------------------------------------------------------
1 | """add_operator_positions_to_pipeline_revision
2 |
3 | Revision ID: b76f7b29a6e7
4 | Revises: d259ec167e13
5 | Create Date: 2025-10-22 03:10:20.279675
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = 'b76f7b29a6e7'
15 | down_revision = 'd259ec167e13'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade():
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column('pipelinerevision', sa.Column('positions', sa.JSON(), nullable=False, server_default='[]'))
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_column('pipelinerevision', 'positions')
29 | # ### end Alembic commands ###
30 |
--------------------------------------------------------------------------------
/cli/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-cli"
3 | version = "0.1.0"
4 | description = "Command-line interface for InteractEM pipeline management"
5 | authors = [{name = "Rajat Bhattarai", email = "basistharajat@gmail.com"}]
6 | readme = "README.md"
7 | requires-python = ">=3.10"
8 | dependencies = [
9 | "interactem-core",
10 | "typer>=0.19.2,<1",
11 | "rich>=13.7.1,<14",
12 | "httpx>=0.28.1,<1",
13 | "jinja2>=3.1.0,<4",
14 | ]
15 |
16 | [project.scripts]
17 | interactem = "interactem.cli.main:app"
18 |
19 | [tool.uv.sources]
20 | interactem-core = { path = "../backend/core", editable = true }
21 |
22 | [tool.poetry]
23 | packages = [{include = "interactem"}]
24 |
25 | [tool.poetry.scripts]
26 | interactem = "interactem.cli.main:app"
27 |
28 | [tool.poetry.dependencies]
29 | interactem-core = {path = "../backend/core", develop = true}
30 |
31 | [build-system]
32 | requires = ["poetry-core"]
33 | build-backend = "poetry.core.masonry.api"
--------------------------------------------------------------------------------
/frontend/interactEM/src/utils/deployments.ts:
--------------------------------------------------------------------------------
1 | import type { PipelineDeploymentState } from "../client"
2 |
3 | export const getDeploymentStateColor = (
4 | state: PipelineDeploymentState,
5 | ): "default" | "primary" | "secondary" | "error" => {
6 | const STATE_COLORS: Record<
7 | PipelineDeploymentState,
8 | "default" | "primary" | "secondary" | "error"
9 | > = {
10 | pending: "secondary",
11 | running: "primary",
12 | cancelled: "default",
13 | failed_to_start: "error",
14 | failure_on_agent: "error",
15 | assigned_agents: "secondary",
16 | }
17 |
18 | return STATE_COLORS[state] || "default"
19 | }
20 |
21 | export const isActiveDeploymentState = (
22 | state: PipelineDeploymentState,
23 | ): boolean => {
24 | return state === "running" || state === "pending"
25 | }
26 |
27 | export const formatDeploymentState = (
28 | state: PipelineDeploymentState,
29 | ): string => {
30 | return state.replace("_", " ")
31 | }
32 |
--------------------------------------------------------------------------------
/docs/source/_static/css/custom.css:
--------------------------------------------------------------------------------
1 | .muted-card {
2 | opacity: 0.5;
3 | color: #6c757d !important;
4 | }
5 |
6 | .muted-card .card-title {
7 | color: #6c757d !important;
8 | }
9 |
10 | .headerlink {
11 | font-size: 0.8em;
12 | vertical-align: middle;
13 | }
14 |
15 | /* Make h2 and below headers smaller */
16 | .content h2,
17 | article h2,
18 | .rst-content h2 {
19 | font-size: 1.5rem !important;
20 | }
21 |
22 | .content h3,
23 | article h3,
24 | .rst-content h3 {
25 | font-size: 1.3rem !important;
26 | }
27 |
28 | .content h4,
29 | article h4,
30 | .rst-content h4 {
31 | font-size: 1.1rem !important;
32 | }
33 |
34 | .content h5,
35 | article h5,
36 | .rst-content h5 {
37 | font-size: 1rem !important;
38 | }
39 |
40 | .content h6,
41 | article h6,
42 | .rst-content h6 {
43 | font-size: 0.9rem !important;
44 | }
45 |
46 | .readme-only {
47 | display: none;
48 | }
49 |
50 | .video-wrapper {
51 | margin: 1rem 0;
52 | }
53 |
--------------------------------------------------------------------------------
/backend/rdma/include/RdmaProxyService.hpp:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * @file RdmaProxyService.hpp
4 | * @brief Main service orchestrator that coordinates all RDMA operations
5 | *
6 | * RdmaProxyService serves as the primary interface for RDMA proxy functionality,
7 | * managing sender, receiver, and transporter components to facilitate efficient
8 | * inter-node communication in HPC environments.
9 | */
10 |
11 | #include
12 | #include
13 | #include
14 | #include "op_receiver.hpp"
15 | #include "op_sender.hpp"
16 | #include "prx_transporter.hpp"
17 |
18 | namespace interactEM {
19 |
20 | class RdmaProxyService {
21 |
22 | private:
23 | OpSender sender_;
24 | PrxTransporter transporter_;
25 | OpReceiver receiver_;
26 |
27 | public:
28 | RdmaProxyService() = default;
29 | ~RdmaProxyService() = default;
30 |
31 | // void initialize();
32 | void stop();
33 |
34 | };
35 |
36 | }
--------------------------------------------------------------------------------
/cli/interactem/cli/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from pydantic import BaseModel
4 |
5 | from interactem.core.models.spec import OperatorSpec
6 |
7 |
8 | class PipelineData(BaseModel):
9 | operators: list[dict] = []
10 | ports: list[dict] = []
11 | edges: list[dict] = []
12 |
13 |
14 | class PipelinePayload(BaseModel):
15 | data: PipelineData
16 |
17 |
18 | class PipelineResponse(BaseModel):
19 | id: str
20 | name: str
21 | data: PipelineData
22 | owner_id: str
23 | created_at: datetime
24 | updated_at: datetime
25 | current_revision_id: int
26 |
27 |
28 | class PipelinesListResponse(BaseModel):
29 | data: list[PipelineResponse]
30 | count: int
31 |
32 |
33 | class TemplateContext(BaseModel):
34 | """Context data for Jinja2 templates."""
35 |
36 | spec: OperatorSpec
37 | name: str
38 | function_name: str
39 | base_image: str
40 | additional_packages: list[str] | None = None
41 |
--------------------------------------------------------------------------------
/operators/center-of-mass-plot/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "287f81c7-4549-4fe6-aa1e-c915761a02e8",
3 | "image": "ghcr.io/nersc/interactem/center-of-mass-plot",
4 | "label": "Center of Mass Plot",
5 | "description": "Plots the center of mass for a particular scan",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "com",
11 | "description": "Center of Mass"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "image",
17 | "label": "Output matplotlib image",
18 | "type": "image",
19 | "description": "Center of mass image"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "xy_rtheta",
25 | "type": "str-enum",
26 | "description": "Colormap to use for the plot",
27 | "options": [
28 | "xy",
29 | "rtheta"
30 | ],
31 | "default": "xy",
32 | "label": "Mode",
33 | "required": true
34 | }
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/backend/orchestrator/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-orchestrator"
3 | version = "0.1.0"
4 | description = ""
5 | authors = [{name = "Sam Welborn", email = "swelborn@lbl.gov"}]
6 | readme = "README.md"
7 | requires-python = ">=3.10"
8 | dependencies = [
9 | "requests>=2.32.4,<3",
10 | "interactem-core",
11 | "pydantic-settings>=2.4,<3",
12 | "aiohttp>=3.10.5,<4",
13 | "transitions>=0.9.3,<1",
14 | ]
15 |
16 | [tool.uv.sources]
17 | interactem-core = { path = "../core", editable = true }
18 |
19 | [dependency-groups]
20 | dev = [
21 | "pytest>=9.0.1,<10",
22 | "pytest-mock>=3.14.0,<4",
23 | ]
24 |
25 | [tool.poetry]
26 | packages = [{ include = "interactem" }]
27 |
28 | [tool.poetry.dependencies]
29 | interactem-core = {path = "../core", develop = true}
30 |
31 | [build-system]
32 | requires = ["poetry-core"]
33 | build-backend = "poetry.core.masonry.api"
34 |
35 | [tool.ruff]
36 | target-version = "py310"
37 | extend = "../../.ruff.toml"
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: help install build clean serve autobuild sync-readmes
2 |
3 | UV := uv
4 |
5 | help:
6 | @echo "Please use \`make ' where is one of"
7 | @echo " build to build the HTML documentation"
8 | @echo " clean to remove generated documentation files"
9 | @echo " serve to build and serve the documentation"
10 | @echo " autobuild to auto-rebuild on file changes"
11 | @echo " sync-readmes to regenerate README files from docs"
12 |
13 | sync-readmes:
14 | $(UV) run scripts/sync_readmes.py
15 |
16 | build: sync-readmes
17 | $(UV) run sphinx-build -n -b html source _build/html
18 |
19 | sync-check:
20 | $(UV) run scripts/sync_readmes.py --check
21 |
22 | clean:
23 | rm -rf _build
24 |
25 | serve: build
26 | @echo "Serving documentation at http://localhost:8000"
27 | @cd _build/html && $(UV) run python -m http.server
28 |
29 | autobuild:
30 | $(UV) run sphinx-autobuild source _build/html --open-browser --fresh-env
31 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/contexts/dnd.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type FC,
3 | type ReactNode,
4 | createContext,
5 | useContext,
6 | useState,
7 | } from "react"
8 |
9 | type DnDContextType = [
10 | T | null,
11 | React.Dispatch> | null,
12 | ]
13 |
14 | export const DnDContext = createContext<
15 | [any | null, React.Dispatch> | null]
16 | >([null, null])
17 |
18 | interface DnDProviderProps {
19 | children: ReactNode
20 | }
21 |
22 | export const DnDProvider: FC<{ children: ReactNode }> = ({
23 | children,
24 | }: DnDProviderProps) => {
25 | const [value, setValue] = useState(null)
26 |
27 | return (
28 | }>
29 | {children}
30 |
31 | )
32 | }
33 |
34 | export default DnDContext
35 |
36 | export const useDnD = () => {
37 | return useContext(DnDContext) as DnDContextType
38 | }
39 |
--------------------------------------------------------------------------------
/backend/metrics/README.md:
--------------------------------------------------------------------------------
1 | # InteractEM - Metrics
2 |
3 | A Prometheus-based metrics collection system for monitoring InteractEM pipeline performance, operator efficiency, and system status with real-time visualization in Grafana.
4 |
5 | ## Architecture
6 | - **InteractEM** (with Metrics Server on port 8001) exposes metrics
7 | - **Prometheus** (port 9090) scrapes metrics every 5 seconds
8 | - **Grafana** (port 3000) queries Prometheus and displays dashboards
9 |
10 | ## Local Development Environment
11 |
12 | | Service | URL | Description |
13 | |---------|-----|-------------|
14 | | **Metrics Server** | `http://localhost:8001` | Prometheus metrics endpoint |
15 | | **Prometheus** | `http://localhost:9090` | Time-series database and monitoring |
16 | | **Grafana** | `http://localhost:3000` | Visualization and dashboards |
17 |
18 | **Access the Dashboard:**
19 | 1. Navigate to Grafana at `http://localhost:3000`
20 | 2. Go to **Dashboards** → **InteractEM** → **Interactem Pipeline Monitoring**
--------------------------------------------------------------------------------
/backend/rdma/libs/thallium/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | # Fetch the nlohmann_json library
2 | include(FetchContent)
3 | FetchContent_Declare(
4 | json
5 | GIT_REPOSITORY https://github.com/nlohmann/json.git
6 | GIT_TAG v3.11.2 # specify a version tag
7 | )
8 | FetchContent_MakeAvailable(json)
9 |
10 | # Thallium engine library
11 | add_library(thallium_engine SHARED
12 | src/eng_utils.cpp
13 | src/eng_provider.cpp
14 | # src/eng_dispatcher.cpp
15 | src/eng_registry.cpp
16 | )
17 |
18 | # Include directories for thallium library
19 | target_include_directories(thallium_engine PUBLIC
20 | ${CMAKE_CURRENT_SOURCE_DIR}/../../common/include
21 | ${CMAKE_CURRENT_SOURCE_DIR}/../argobots/include
22 | ${CMAKE_CURRENT_SOURCE_DIR}/../thallium/include
23 | )
24 |
25 | # Link external thallium dependency
26 | target_link_libraries(thallium_engine PUBLIC
27 | thallium
28 | nlohmann_json::nlohmann_json
29 | )
30 |
31 | target_compile_features(thallium_engine PUBLIC cxx_std_17)
32 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/client/generated/client.gen.ts:
--------------------------------------------------------------------------------
1 | // This file is auto-generated by @hey-api/openapi-ts
2 |
3 | import {
4 | type Config,
5 | type ClientOptions as DefaultClientOptions,
6 | createClient,
7 | createConfig,
8 | } from "@hey-api/client-axios"
9 | import type { ClientOptions } from "./types.gen"
10 |
11 | /**
12 | * The `createClientConfig()` function will be called on client initialization
13 | * and the returned object will become the client's initial configuration.
14 | *
15 | * You may want to initialize your client this way instead of calling
16 | * `setConfig()`. This is useful for example if you're using Next.js
17 | * to ensure your client always has the correct values.
18 | */
19 | export type CreateClientConfig =
20 | (
21 | override?: Config,
22 | ) => Config & T>
23 |
24 | export const client = createClient(createConfig())
25 |
--------------------------------------------------------------------------------
/operators/bin-sparse-partial/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "2af8232a-b0e0-40ea-9757-d63d1ae28555",
3 | "image": "ghcr.io/nersc/interactem/bin-sparse-partial",
4 | "label": "Partial sparse frame binning",
5 | "description": "Bins sparse frames to reduce their overall size",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "frame",
11 | "description": "A batch of sparse frames"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "frame_bin_partial",
17 | "label": "The output",
18 | "type": "com_partial",
19 | "description": "A batch of binned frames"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "bin_value",
25 | "label": "Binning value",
26 | "type": "int",
27 | "default": "2",
28 | "description": "The value to bin each frame by",
29 | "required": true
30 | }
31 | ],
32 | "parallel_config": {
33 | "type": "embarrassing"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/backend/app/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "workbench.colorCustomizations": {
3 | "activityBar.activeBackground": "#65c89b",
4 | "activityBar.background": "#65c89b",
5 | "activityBar.foreground": "#15202b",
6 | "activityBar.inactiveForeground": "#15202b99",
7 | "activityBarBadge.background": "#945bc4",
8 | "activityBarBadge.foreground": "#e7e7e7",
9 | "commandCenter.border": "#15202b99",
10 | "sash.hoverBorder": "#65c89b",
11 | "statusBar.background": "#42b883",
12 | "statusBar.foreground": "#15202b",
13 | "statusBarItem.hoverBackground": "#359268",
14 | "statusBarItem.remoteBackground": "#42b883",
15 | "statusBarItem.remoteForeground": "#15202b",
16 | "titleBar.activeBackground": "#42b883",
17 | "titleBar.activeForeground": "#15202b",
18 | "titleBar.inactiveBackground": "#42b88399",
19 | "titleBar.inactiveForeground": "#15202b99"
20 | },
21 | "peacock.remoteColor": "#42b883"
22 | }
--------------------------------------------------------------------------------
/backend/rdma/CMakeLists.txt:
--------------------------------------------------------------------------------
1 |
2 | cmake_minimum_required(VERSION 3.15...3.26)
3 |
4 | project(thallium-mochi-rdma VERSION 0.1.0 LANGUAGES CXX)
5 |
6 | # Set C++ standard
7 | set(CMAKE_CXX_STANDARD 17)
8 | set(CMAKE_CXX_STANDARD_REQUIRED ON)
9 |
10 | find_package(thallium REQUIRED)
11 | find_package(PkgConfig REQUIRED)
12 |
13 | add_subdirectory(common)
14 | add_subdirectory(libs)
15 | add_subdirectory(src)
16 |
17 | # Main executable
18 | # add_executable(rdma_proxy main.cpp bindings.cpp)
19 | add_executable(rdma_proxy main.cpp)
20 |
21 | # Include directories for main executable
22 | target_include_directories(rdma_proxy PRIVATE
23 | ${CMAKE_CURRENT_SOURCE_DIR}/include
24 | ${CMAKE_CURRENT_SOURCE_DIR}/common/include
25 | ${CMAKE_CURRENT_SOURCE_DIR}/libs/thallium/include
26 | ${CMAKE_CURRENT_SOURCE_DIR}/libs/argobots/include
27 | )
28 |
29 | # Link libraries to main executable
30 | target_link_libraries(rdma_proxy PRIVATE
31 | rdma_proxy_core
32 | rdma_external_libs
33 | thallium
34 | )
--------------------------------------------------------------------------------
/operators/electron-count-save/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "12345678-1234-1234-1234-1432567890cd",
3 | "image": "ghcr.io/nersc/interactem/electron-count-save:latest",
4 | "label": "Electron Count Saver",
5 | "description": "Saves electron count to a file",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "scan",
11 | "description": "Sparse scan"
12 | }
13 | ],
14 | "parameters": [
15 | {
16 | "name": "output_dir",
17 | "label": "Output directory",
18 | "type": "mount",
19 | "default": "~/ncem_raw_data/counted_data/",
20 | "description": "This is where the electron counted data is saved",
21 | "required": true
22 | },
23 | {
24 | "name": "suffix",
25 | "label": "Filename suffix",
26 | "type": "str-enum",
27 | "default": "",
28 | "description": "Suffix to add to the filename",
29 | "required": true,
30 | "options": ["", "_counted"]
31 | }
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "biome.enabled": true,
3 | "[javascript]": {
4 | "editor.defaultFormatter": "biomejs.biome",
5 | "editor.formatOnSave": true,
6 | "editor.codeActionsOnSave": {
7 | "source.organizeImports": "explicit"
8 | }
9 | },
10 | "[javascriptreact]": {
11 | "editor.defaultFormatter": "biomejs.biome",
12 | "editor.formatOnSave": true,
13 | "editor.codeActionsOnSave": {
14 | "source.organizeImports": "explicit"
15 | }
16 | },
17 | "[typescript]": {
18 | "editor.defaultFormatter": "biomejs.biome",
19 | "editor.formatOnSave": true,
20 | "editor.codeActionsOnSave": {
21 | "source.organizeImports": "explicit"
22 | }
23 | },
24 | "[typescriptreact]": {
25 | "editor.defaultFormatter": "biomejs.biome",
26 | "editor.formatOnSave": true,
27 | "editor.codeActionsOnSave": {
28 | "source.organizeImports": "explicit"
29 | }
30 | },
31 | "biome.lsp.bin": "interactEM/node_modules/@biomejs/biome/bin/biome"
32 | }
33 |
--------------------------------------------------------------------------------
/operators/read-tem-data/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "389712a9-edb9-4dcb-89d8-c13264a0c356",
3 | "label": "Read TEM data",
4 | "description": "This reads data from disk using ncempy and sends it on.",
5 | "image": "ghcr.io/nersc/interactem/read-tem-data:latest",
6 | "outputs": [
7 | {
8 | "name": "data",
9 | "label": "Output array",
10 | "description": "Array output",
11 | "type": "bytes"
12 | }
13 | ],
14 | "parameters": [
15 | {
16 | "name": "raw_data_dir",
17 | "label": "Raw data directory",
18 | "description": "The directory containing the files to read.",
19 | "type": "mount",
20 | "default": "~/test_data",
21 | "required": true
22 | },
23 | {
24 | "name": "file",
25 | "label": "File name",
26 | "description": "The name of the file to read.",
27 | "type": "str",
28 | "default": "file.emd",
29 | "required": true
30 | }
31 | ],
32 | "parallel_config": {
33 | "type": "none"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/pipelines/hudlistbutton.tsx:
--------------------------------------------------------------------------------
1 | import { IconButton, Tooltip } from "@mui/material"
2 | import type React from "react"
3 |
4 | interface HudListButtonProps {
5 | tooltip: string
6 | icon: React.ReactNode
7 | onClick: () => void
8 | active?: boolean
9 | }
10 |
11 | export const HudListButton: React.FC = ({
12 | tooltip,
13 | icon,
14 | onClick,
15 | active = false,
16 | }) => {
17 | return (
18 |
19 |
32 | {icon}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/docs/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-docs"
3 | version = "0.1.0"
4 | description = "interactEM documentation"
5 | authors = [{name = "interactEM developers"}]
6 | readme = "README.md"
7 | requires-python = ">=3.12"
8 | dependencies = [
9 | "sphinx>=8.1.3,<9",
10 | "sphinx-multiversion>=0.2.4,<1",
11 | "myst-parser>=4.0.0,<5",
12 | "sphinxcontrib-mermaid>=1.0.0,<2",
13 | "sphinx-design>=0.6.1,<1",
14 | "furo>=2025.7.19,<2026",
15 | "sphinx-copybutton>=0.5.2,<1",
16 | "markdown-code-symlinks",
17 | ]
18 |
19 | [tool.uv.sources]
20 | markdown-code-symlinks = { git = "https://github.com/SymbiFlow/sphinxcontrib-markdown-symlinks.git", rev = "4ead1c22270188cd0529741679769c56d9c341bf" }
21 |
22 | [dependency-groups]
23 | dev = [
24 | "sphinx-autobuild>=2024.10.3,<2025",
25 | ]
26 |
27 | [tool.poetry]
28 | package-mode = false
29 |
30 | [tool.poetry.dependencies]
31 | markdown-code-symlinks = { git = "https://github.com/SymbiFlow/sphinxcontrib-markdown-symlinks.git", rev = "4ead1c22270188cd0529741679769c56d9c341bf" }
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/60a2bc7c4aea_add_pipeline_name.py:
--------------------------------------------------------------------------------
1 | """add pipeline name
2 |
3 | Revision ID: 60a2bc7c4aea
4 | Revises: 8254e462f00f
5 | Create Date: 2025-04-19 13:04:23.374238
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = '60a2bc7c4aea'
15 | down_revision = '8254e462f00f'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade():
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column('pipeline', sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True))
23 | op.create_index(op.f('ix_pipeline_name'), 'pipeline', ['name'], unique=False)
24 | # ### end Alembic commands ###
25 |
26 |
27 | def downgrade():
28 | # ### commands auto generated by Alembic - please adjust! ###
29 | op.drop_index(op.f('ix_pipeline_name'), table_name='pipeline')
30 | op.drop_column('pipeline', 'name')
31 | # ### end Alembic commands ###
32 |
--------------------------------------------------------------------------------
/backend/core/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-core"
3 | version = "0.1.0"
4 | description = ""
5 | readme = "README.md"
6 | authors = [
7 | {name = "Sam Welborn", email = "swelborn@lbl.gov"},
8 | {name = "Chris Harris", email = "cjh@lbl.gov"}
9 | ]
10 | requires-python = ">=3.10"
11 | dependencies = [
12 | "pydantic>=2.12.3,<3",
13 | "networkx>=3.3,<3.5",
14 | "nats-py>=2.11.0,<3",
15 | "nkeys>=0.2.1,<0.3",
16 | "tenacity>=9.0.0,<10",
17 | "pydantic-settings>=2.10.1,<3",
18 | "faststream[nats] @ git+https://github.com/fil1n/faststream.git@8c14951daec416f22469deaed805ff5db27e3b44",
19 | "anyio>=4.11.0,<5",
20 | ]
21 |
22 | [dependency-groups]
23 | dev = [
24 | "pytest>=9.0.1,<10",
25 | "pydantic-to-typescript>=2.0.0,<3",
26 | ]
27 |
28 | [tool.poetry]
29 | packages = [{ include = "interactem" }]
30 |
31 | [build-system]
32 | requires = ["poetry-core"]
33 | build-backend = "poetry.core.masonry.api"
34 |
35 | [tool.ruff]
36 | target-version = "py310"
37 | extend = "../../.ruff.toml"
38 | extend-exclude = ["_export.py"]
39 |
--------------------------------------------------------------------------------
/scripts/copy-dotenv.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Script to copy all .env.example files to .env in their respective folders
4 | # Only copies if .env doesn't already exist
5 |
6 | set -euo pipefail
7 |
8 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9 | REPO_ROOT="$(dirname "$SCRIPT_DIR")"
10 |
11 | echo "Searching for .env.example files in $REPO_ROOT..."
12 | echo ""
13 |
14 | copied_count=0
15 | skipped_count=0
16 |
17 | # Find all .env.example files and copy them
18 | while IFS= read -r -d '' env_example_file; do
19 | env_dir="$(dirname "$env_example_file")"
20 | env_file="$env_dir/.env"
21 |
22 | if [ -f "$env_file" ]; then
23 | echo "⊘ Skipped: $env_file (already exists)"
24 | skipped_count=$((skipped_count + 1))
25 | else
26 | cp "$env_example_file" "$env_file"
27 | echo "✓ Copied: $env_example_file → $env_file"
28 | copied_count=$((copied_count + 1))
29 | fi
30 | done < <(find "$REPO_ROOT" -name ".env.example" -type f -print0)
31 |
32 | echo ""
33 | echo "Summary:"
34 | echo " Copied: $copied_count"
35 | echo " Skipped: $skipped_count"
36 |
--------------------------------------------------------------------------------
/operators/random-image/run.py:
--------------------------------------------------------------------------------
1 | import io
2 | import time
3 | from typing import Any
4 |
5 | import numpy as np
6 | from PIL import Image
7 |
8 | from interactem.core.logger import get_logger
9 | from interactem.core.models.messages import BytesMessage, MessageHeader, MessageSubject
10 | from interactem.operators.operator import operator
11 |
12 | logger = get_logger()
13 |
14 |
15 | @operator
16 | def random_image(
17 | inputs: BytesMessage | None, parameters: dict[str, Any]
18 | ) -> BytesMessage | None:
19 | width = int(parameters.get("width", 100))
20 | height = int(parameters.get("height", 100))
21 | interval = int(parameters.get("interval", 2))
22 |
23 | time.sleep(interval)
24 |
25 | random_data = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8)
26 | image = Image.fromarray(random_data, "RGB")
27 | byte_array = io.BytesIO()
28 | image.save(byte_array, format="JPEG")
29 | byte_array.seek(0)
30 | header = MessageHeader(subject=MessageSubject.BYTES, meta={})
31 |
32 | return BytesMessage(header=header, data=byte_array.getvalue())
33 |
--------------------------------------------------------------------------------
/operators/random-image/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "92345678-1234-1234-1234-1234567890ab",
3 | "image": "ghcr.io/nersc/interactem/random-image:latest",
4 | "label": "Random Image",
5 | "description": "Generates a random image",
6 | "outputs": [
7 | {
8 | "name": "out",
9 | "label": "The image",
10 | "type": "image",
11 | "description": "The image"
12 | }
13 | ],
14 | "parameters": [
15 | {
16 | "name": "width",
17 | "label": "Image width",
18 | "type": "int",
19 | "default": "100",
20 | "description": "The width of the image",
21 | "required": true
22 | },
23 | {
24 | "name": "height",
25 | "label": "Image height",
26 | "type": "int",
27 | "default": "100",
28 | "description": "The height of the image",
29 | "required": true
30 | },
31 | {
32 | "name": "interval",
33 | "label": "Interval",
34 | "type": "int",
35 | "default": "2",
36 | "description": "The interval at which to generate the image",
37 | "required": true
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/backend/callout/service/go.mod:
--------------------------------------------------------------------------------
1 | module distiller-callout
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/aricart/callout.go v0.2.0
7 | github.com/go-playground/validator/v10 v10.25.0
8 | github.com/golang-jwt/jwt/v5 v5.2.2
9 | github.com/joho/godotenv v1.5.1
10 | github.com/nats-io/jwt/v2 v2.7.3
11 | github.com/nats-io/nats-server/v2 v2.11.1
12 | github.com/nats-io/nats.go v1.39.1
13 | github.com/nats-io/nkeys v0.4.10
14 | )
15 |
16 | require (
17 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect
18 | github.com/go-playground/locales v0.14.1 // indirect
19 | github.com/go-playground/universal-translator v0.18.1 // indirect
20 | github.com/google/go-tpm v0.9.3 // indirect
21 | github.com/klauspost/compress v1.18.0 // indirect
22 | github.com/leodido/go-urn v1.4.0 // indirect
23 | github.com/minio/highwayhash v1.0.3 // indirect
24 | github.com/nats-io/nuid v1.0.1 // indirect
25 | golang.org/x/crypto v0.45.0 // indirect
26 | golang.org/x/net v0.47.0 // indirect
27 | golang.org/x/sys v0.38.0 // indirect
28 | golang.org/x/text v0.31.0 // indirect
29 | golang.org/x/time v0.11.0 // indirect
30 | )
31 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/api/routes/operators.py:
--------------------------------------------------------------------------------
1 |
2 | from fastapi import APIRouter, Query
3 |
4 | from interactem.app.api.deps import CurrentUser
5 | from interactem.app.models import OperatorSpecs
6 | from interactem.app.operators import fetch_operators
7 | from interactem.core.logger import get_logger
8 |
9 | logger = get_logger()
10 | router = APIRouter()
11 |
12 | _operators = None
13 |
14 |
15 | @router.get("/", response_model=OperatorSpecs)
16 | async def read_operators(
17 | current_user: CurrentUser,
18 | refresh: bool = Query(False, description="Force refresh of operators cache"),
19 | ) -> OperatorSpecs:
20 | """
21 | Retrieve available operators. Use refresh=true to invalidate cache and fetch fresh data.
22 | """
23 | global _operators
24 |
25 | if refresh or _operators is None:
26 | if refresh:
27 | logger.info("Refreshing operators cache due to refresh parameter")
28 | _operators = await fetch_operators()
29 |
30 | ops = OperatorSpecs(data=_operators)
31 | logger.info(f"Operators found: {[op.image for op in ops.data]}")
32 | return ops
33 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/hooks/nats/useTableData.ts:
--------------------------------------------------------------------------------
1 | import { STREAM_TABLES } from "../../constants/nats"
2 | import { useStreamMessage } from "./useStreamMessage"
3 |
4 | export interface TableRow {
5 | [key: string]: string | number | boolean | null
6 | }
7 |
8 | export interface TablesDict {
9 | [tableName: string]: TableRow[]
10 | }
11 |
12 | export interface TablePayload {
13 | [tables: string]: TablesDict
14 | }
15 |
16 | export const useTableData = (operatorID: string): TablePayload | null => {
17 | const subject = `${STREAM_TABLES}.${operatorID}`
18 |
19 | const { data } = useStreamMessage({
20 | streamName: STREAM_TABLES,
21 | subject,
22 | transform: (jsonData) => {
23 | // Basic validation: Check if it's a non-null object
24 | if (typeof jsonData !== "object" || jsonData === null) {
25 | console.error(
26 | `Received invalid table data structure for ${operatorID}: Expected an object, got ${typeof jsonData}.`,
27 | )
28 | return null
29 | }
30 | return jsonData as TablePayload
31 | },
32 | })
33 |
34 | return data
35 | }
36 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from sqlalchemy import Engine
4 | from sqlmodel import Session, select
5 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
6 |
7 | from interactem.app.core.db import engine
8 |
9 | logging.basicConfig(level=logging.INFO)
10 | logger = logging.getLogger(__name__)
11 |
12 | max_tries = 60 * 5 # 5 minutes
13 | wait_seconds = 1
14 |
15 |
16 | @retry(
17 | stop=stop_after_attempt(max_tries),
18 | wait=wait_fixed(wait_seconds),
19 | before=before_log(logger, logging.INFO),
20 | after=after_log(logger, logging.WARN),
21 | )
22 | def init(db_engine: Engine) -> None:
23 | try:
24 | # Try to create session to check if DB is awake
25 | with Session(db_engine) as session:
26 | session.exec(select(1))
27 | except Exception as e:
28 | logger.error(e)
29 | raise e
30 |
31 |
32 | def main() -> None:
33 | logger.info("Initializing service")
34 | init(engine)
35 | logger.info("Service finished initializing")
36 |
37 |
38 | if __name__ == "__main__":
39 | main()
40 |
--------------------------------------------------------------------------------
/backend/callout/test/run.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from core.nats import create_or_update_stream, nc
4 | from core.nats.streams import AGENTS_STREAM_CONFIG
5 | from nats.aio.client import Client as NATSClient
6 | from pydantic_settings import BaseSettings, SettingsConfigDict
7 |
8 |
9 | class Settings(BaseSettings):
10 | model_config = SettingsConfigDict(env_file=".env", extra="ignore")
11 | DISTILLER_TOKEN: str
12 |
13 |
14 | async def main():
15 | client = await nc(servers=["nats://localhost:4222"], name="test-nkeys")
16 | js = client.jetstream()
17 | await create_or_update_stream(AGENTS_STREAM_CONFIG, js)
18 | await client.close()
19 | cfg = Settings() # type: ignore
20 |
21 | client = NATSClient()
22 | await client.connect(
23 | servers=["nats://localhost:4222"], name="test-token", token=cfg.DISTILLER_TOKEN
24 | )
25 | js = client.jetstream()
26 | await create_or_update_stream(AGENTS_STREAM_CONFIG, js)
27 |
28 | await asyncio.sleep(3)
29 | await create_or_update_stream(AGENTS_STREAM_CONFIG, js)
30 |
31 |
32 | if __name__ == "__main__":
33 | asyncio.run(main())
34 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/ac46c9f37d67_add_pipelines_table.py:
--------------------------------------------------------------------------------
1 | """Add pipelines table
2 |
3 | Revision ID: ac46c9f37d67
4 | Revises: 1a31ce608336
5 | Create Date: 2024-08-09 14:13:21.725389
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = 'ac46c9f37d67'
13 | down_revision = '1a31ce608336'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.create_table('pipeline',
21 | sa.Column('data', sa.JSON(), nullable=True),
22 | sa.Column('running', sa.Boolean(), nullable=False),
23 | sa.Column('id', sa.Uuid(), nullable=False),
24 | sa.Column('owner_id', sa.Uuid(), nullable=False),
25 | sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
26 | sa.PrimaryKeyConstraint('id')
27 | )
28 | # ### end Alembic commands ###
29 |
30 |
31 | def downgrade():
32 | # ### commands auto generated by Alembic - please adjust! ###
33 | op.drop_table('pipeline')
34 | # ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/backend_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from sqlalchemy import Engine
4 | from sqlmodel import Session, select
5 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
6 |
7 | from interactem.app.core.db import engine
8 |
9 | logging.basicConfig(level=logging.INFO)
10 | logger = logging.getLogger(__name__)
11 |
12 | max_tries = 60 * 5 # 5 minutes
13 | wait_seconds = 1
14 |
15 |
16 | @retry(
17 | stop=stop_after_attempt(max_tries),
18 | wait=wait_fixed(wait_seconds),
19 | before=before_log(logger, logging.INFO),
20 | after=after_log(logger, logging.WARN),
21 | )
22 | def init(db_engine: Engine) -> None:
23 | try:
24 | with Session(db_engine) as session:
25 | # Try to create session to check if DB is awake
26 | session.exec(select(1))
27 | except Exception as e:
28 | logger.error(e)
29 | raise e
30 |
31 |
32 | def main() -> None:
33 | logger.info("Initializing service")
34 | init(engine)
35 | logger.info("Service finished initializing")
36 |
37 |
38 | if __name__ == "__main__":
39 | main()
40 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/image.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from "@mui/material/styles"
2 | import type React from "react"
3 | import { useEffect, useState } from "react"
4 |
5 | interface ImageProps {
6 | imageData: Uint8Array | null
7 | }
8 |
9 | interface ImgProps {
10 | width?: string
11 | height?: string
12 | }
13 |
14 | const Img = styled("img")(({ width = "100%", height = "100%" }) => ({
15 | width,
16 | height,
17 | objectFit: "contain",
18 | }))
19 |
20 | const Image: React.FC = ({ imageData }) => {
21 | const [imageSrc, setImageSrc] = useState(null)
22 |
23 | useEffect(() => {
24 | if (imageData) {
25 | const url = URL.createObjectURL(
26 | // TODO: Pass the MIME type with the image data
27 | new Blob([new Uint8Array(imageData)], { type: "image/jpeg" }),
28 | )
29 | setImageSrc(url)
30 |
31 | return () => {
32 | URL.revokeObjectURL(url)
33 | }
34 | }
35 | }, [imageData])
36 |
37 | return (
38 | {imageSrc ?

:
Waiting...
}
39 | )
40 | }
41 |
42 | export default Image
43 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/auth/api.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from "@tanstack/react-query"
2 | import { AUTH_QUERY_KEYS } from "../constants/tanstack"
3 |
4 | const queryClient = new QueryClient()
5 |
6 | type LoginResult = {
7 | success: boolean
8 | error?: Error
9 | }
10 |
11 | export async function loginInteractem(
12 | external_token: string,
13 | ): Promise {
14 | try {
15 | queryClient.setQueryData(AUTH_QUERY_KEYS.externalToken, external_token)
16 | // Trigger a refresh of the internal auth
17 | await queryClient.invalidateQueries({
18 | queryKey: AUTH_QUERY_KEYS.internalToken,
19 | })
20 |
21 | return { success: true }
22 | } catch (error) {
23 | console.error("Failed to login to InteractEM:", error)
24 | return {
25 | success: false,
26 | error:
27 | error instanceof Error ? error : new Error("Unknown error occurred"),
28 | }
29 | }
30 | }
31 |
32 | // We want to use the interactemQueryClient since we cannot use useQueryClient()
33 | // outside of a component (i.e., inside of the async function that we want to call)
34 | export { queryClient as interactemQueryClient }
35 |
--------------------------------------------------------------------------------
/backend/launcher/interactem/launcher/config.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from pydantic import AnyWebsocketUrl, NatsDsn, model_validator
4 | from pydantic_settings import BaseSettings, SettingsConfigDict
5 |
6 |
7 | class Settings(BaseSettings):
8 | model_config = SettingsConfigDict(env_file=".env", extra="ignore")
9 | NATS_SERVER_URL: AnyWebsocketUrl | NatsDsn = NatsDsn("nats://localhost:4222")
10 | SFAPI_KEY_PATH: Path = Path("/secrets/sfapi.pem")
11 | AGENT_PROJECT_DIR: Path
12 | ENV_FILE_PATH: Path
13 | ENV_FILE_DIR: Path | None = None
14 | SFAPI_ACCOUNT: str
15 | SFAPI_QOS: str
16 |
17 | @model_validator(mode="after")
18 | def resolve_path(self) -> "Settings":
19 | self.SFAPI_KEY_PATH = self.SFAPI_KEY_PATH.expanduser().resolve()
20 | if not self.SFAPI_KEY_PATH.is_file():
21 | raise ValueError(f"File not found: {self.SFAPI_KEY_PATH}")
22 | return self
23 |
24 | @model_validator(mode="after")
25 | def env_file_parent(self) -> "Settings":
26 | self.ENV_FILE_DIR = self.ENV_FILE_PATH.parent
27 | return self
28 |
29 |
30 | cfg = Settings() # type: ignore
31 |
--------------------------------------------------------------------------------
/frontend/interactEM/tests/e2e/fixtures/auth.ts:
--------------------------------------------------------------------------------
1 | import { expect, test as base, type Page } from "@playwright/test"
2 |
3 | const username = process.env.FIRST_SUPERUSER_USERNAME
4 | const password = process.env.FIRST_SUPERUSER_PASSWORD
5 |
6 | async function login(page: Page) {
7 | if (!username || !password) {
8 | throw new Error(
9 | "FIRST_SUPERUSER_USERNAME and FIRST_SUPERUSER_PASSWORD must be set for Playwright tests",
10 | )
11 | }
12 |
13 | await page.goto("/", { waitUntil: "domcontentloaded" })
14 | await page.waitForSelector("text=Login")
15 | await page.getByLabel("Username").fill(username)
16 | await page.getByLabel("Password").fill(password)
17 | await page.getByRole("button", { name: /login/i }).click()
18 | await page.waitForSelector(".composer-page", { timeout: 20_000 })
19 | await expect(page.locator(".composer-page")).toBeVisible()
20 | }
21 |
22 | type Fixtures = {
23 | authPage: Page
24 | }
25 |
26 | export const test = base.extend({
27 | authPage: async ({ page }, use) => {
28 | await login(page)
29 | await use(page)
30 | },
31 | })
32 |
33 | export { expect } from "@playwright/test"
34 |
--------------------------------------------------------------------------------
/operators/data-replay/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "12345678-0001-0001-0000-1234567890ab",
3 | "image": "ghcr.io/nersc/interactem/data-replay",
4 | "label": "Data Replayer",
5 | "description": "Data replayer for NCEM data. Choose a raw data directory to mount, and replay it through the pipeline",
6 | "outputs": [
7 | {
8 | "name": "out",
9 | "label": "The output",
10 | "type": "frame",
11 | "description": "Full frame"
12 | }
13 | ],
14 | "parameters": [
15 | {
16 | "name": "raw_data_dir",
17 | "label": "Raw data directory",
18 | "type": "mount",
19 | "default": "~/ncem_raw_data",
20 | "description": "This is where the raw data files (*.data) are located",
21 | "required": true
22 | },
23 | {
24 | "name": "scan_num",
25 | "label": "Scan number",
26 | "type": "int",
27 | "default": "0",
28 | "description": "The scan number to be processed",
29 | "required": true
30 | }
31 | ],
32 | "tags": [
33 | {
34 | "value": "ncem-4dcamera",
35 | "description": "Required to run at the edge near the 4d camera."
36 | }
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests/scripts/test_test_pre_start.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | from sqlmodel import select
4 |
5 | from interactem.app.tests_pre_start import init, logger
6 |
7 |
8 | def test_init_successful_connection() -> None:
9 | engine_mock = MagicMock()
10 |
11 | session_mock = MagicMock()
12 | exec_mock = MagicMock(return_value=True)
13 | session_mock.configure_mock(**{"exec.return_value": exec_mock})
14 |
15 | with (
16 | patch("sqlmodel.Session", return_value=session_mock),
17 | patch.object(logger, "info"),
18 | patch.object(logger, "error"),
19 | patch.object(logger, "warn"),
20 | ):
21 | try:
22 | init(engine_mock)
23 | connection_successful = True
24 | except Exception:
25 | connection_successful = False
26 |
27 | assert (
28 | connection_successful
29 | ), "The database connection should be successful and not raise an exception."
30 |
31 | assert session_mock.exec.called_once_with(
32 | select(1)
33 | ), "The session should execute a select statement once."
34 |
--------------------------------------------------------------------------------
/backend/operators/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.formatOnSaveMode": "modifications",
4 | "editor.defaultFormatter": "charliermarsh.ruff",
5 | "workbench.colorCustomizations": {
6 | "activityBar.activeBackground": "#3399ff",
7 | "activityBar.background": "#3399ff",
8 | "activityBar.foreground": "#15202b",
9 | "activityBar.inactiveForeground": "#15202b99",
10 | "activityBarBadge.background": "#bf0060",
11 | "activityBarBadge.foreground": "#e7e7e7",
12 | "commandCenter.border": "#e7e7e799",
13 | "sash.hoverBorder": "#3399ff",
14 | "statusBar.background": "#007fff",
15 | "statusBar.foreground": "#e7e7e7",
16 | "statusBarItem.hoverBackground": "#3399ff",
17 | "statusBarItem.remoteBackground": "#007fff",
18 | "statusBarItem.remoteForeground": "#e7e7e7",
19 | "titleBar.activeBackground": "#007fff",
20 | "titleBar.activeForeground": "#e7e7e7",
21 | "titleBar.inactiveBackground": "#007fff99",
22 | "titleBar.inactiveForeground": "#e7e7e799"
23 | },
24 | "peacock.remoteColor": "#007fff",
25 | }
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests/scripts/test_backend_pre_start.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | from sqlmodel import select
4 |
5 | from interactem.app.backend_pre_start import init, logger
6 |
7 |
8 | def test_init_successful_connection() -> None:
9 | engine_mock = MagicMock()
10 |
11 | session_mock = MagicMock()
12 | exec_mock = MagicMock(return_value=True)
13 | session_mock.configure_mock(**{"exec.return_value": exec_mock})
14 |
15 | with (
16 | patch("sqlmodel.Session", return_value=session_mock),
17 | patch.object(logger, "info"),
18 | patch.object(logger, "error"),
19 | patch.object(logger, "warn"),
20 | ):
21 | try:
22 | init(engine_mock)
23 | connection_successful = True
24 | except Exception:
25 | connection_successful = False
26 |
27 | assert (
28 | connection_successful
29 | ), "The database connection should be successful and not raise an exception."
30 |
31 | assert session_mock.exec.called_once_with(
32 | select(1)
33 | ), "The session should execute a select statement once."
34 |
--------------------------------------------------------------------------------
/backend/agent/interactem/agent/util.py:
--------------------------------------------------------------------------------
1 |
2 |
3 | import os
4 | import shutil
5 | import subprocess
6 |
7 |
8 | # GPU utils
9 | # =============================================================================
10 | def detect_gpu_enabled() -> bool:
11 | visible_devices = (
12 | os.getenv("CUDA_VISIBLE_DEVICES") or os.getenv("NVIDIA_VISIBLE_DEVICES") or ""
13 | ).strip()
14 | if visible_devices and visible_devices.lower() not in {"none", "void", "-1"}:
15 | return True
16 |
17 | if os.path.isdir("/proc/driver/nvidia/gpus"):
18 | return True
19 |
20 | for path in ("/dev/nvidiactl", "/dev/nvidia0", "/dev/nvidia-uvm"):
21 | if os.path.exists(path):
22 | return True
23 |
24 | nvidia_smi = shutil.which("nvidia-smi")
25 | if not nvidia_smi:
26 | return False
27 |
28 | try:
29 | proc = subprocess.run(
30 | [nvidia_smi, "-L"],
31 | stdout=subprocess.PIPE,
32 | stderr=subprocess.DEVNULL,
33 | check=False,
34 | timeout=1.5,
35 | )
36 | except (subprocess.TimeoutExpired, OSError):
37 | return False
38 |
39 | return proc.returncode == 0 and bool(proc.stdout.strip())
40 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/nodes/parametersbutton.tsx:
--------------------------------------------------------------------------------
1 | import SettingsIcon from "@mui/icons-material/Settings"
2 | import { Typography } from "@mui/material"
3 | import type React from "react"
4 | import type { OperatorSpecParameter } from "../../client"
5 | import NodeModalButton from "./nodemodalbutton"
6 | import ParameterUpdater from "./parameterupdater"
7 |
8 | const ParametersButton: React.FC<{
9 | operatorID: string
10 | parameters: OperatorSpecParameter[]
11 | nodeRef: React.RefObject
12 | }> = ({ operatorID, parameters, nodeRef }) => {
13 | return (
14 | }
17 | label="Parameters"
18 | title={null}
19 | >
20 | {parameters.map((param) => (
21 |
26 | ))}
27 | {parameters.length === 0 && (
28 |
29 | No parameters available.
30 |
31 | )}
32 |
33 | )
34 | }
35 |
36 | export default ParametersButton
37 |
--------------------------------------------------------------------------------
/operators/detstream-producer/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "producer": {
3 | "client_type": "producer",
4 | "connect": {
5 | "state_hostname": "192.168.127.2",
6 | "state_port": 15000
7 | },
8 | "process": {
9 | "num_processes": 4,
10 | "num_threads_per_process": 1,
11 | "io_thread_affinity": true
12 | },
13 | "upstream": {
14 | "client_type": "none"
15 | },
16 | "downstream": {
17 | "first_bound_port": 6001,
18 | "hostname_bind": [
19 | "192.168.127.2",
20 | "192.168.127.2",
21 | "192.168.127.2",
22 | "192.168.127.2"
23 | ],
24 | "max_num_downstream_processes": 1
25 | },
26 | "sockopts": {
27 | "sndhwm": 1000,
28 | "io_thread_affinity": true,
29 | "sndtimeo": 10000
30 | },
31 | "ctxopts": {
32 | "scheduler": "SCHED_OTHER",
33 | "io_threads": 1
34 | },
35 | "filepaths": {
36 | "status_json_output_dir": "/mnt/data/",
37 | "save_raw_data_dir": "/output"
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py:
--------------------------------------------------------------------------------
1 | """Add cascade delete relationships
2 |
3 | Revision ID: 1a31ce608336
4 | Revises: d98dd8ec85a3
5 | Create Date: 2024-07-31 22:24:34.447891
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "1a31ce608336"
14 | down_revision = "d98dd8ec85a3"
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.alter_column("item", "owner_id", existing_type=sa.UUID(), nullable=False)
22 | op.drop_constraint("item_owner_id_fkey", "item", type_="foreignkey")
23 | op.create_foreign_key(
24 | None, "item", "user", ["owner_id"], ["id"], ondelete="CASCADE"
25 | )
26 | # ### end Alembic commands ###
27 |
28 |
29 | def downgrade():
30 | # ### commands auto generated by Alembic - please adjust! ###
31 | op.drop_constraint(None, "item", type_="foreignkey")
32 | op.create_foreign_key("item_owner_id_fkey", "item", "user", ["owner_id"], ["id"])
33 | op.alter_column("item", "owner_id", existing_type=sa.UUID(), nullable=True)
34 | # ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/backend/launcher/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-launcher"
3 | version = "0.1.0"
4 | description = ""
5 | authors = [
6 | {name = "Sam Welborn", email = "swelborn@lbl.gov"},
7 | {name = "Chris Harris", email = "cjh@lbl.gov"}
8 | ]
9 | requires-python = ">=3.10"
10 | readme = "README.md"
11 | dependencies = [
12 | "interactem-core",
13 | "interactem-sfapi-models",
14 | "pydantic-settings>=2.4.0,<3",
15 | "sfapi-client>=0.4,<1",
16 | "h11>=0.16.0,<1",
17 | "jinja2>=3.1,<4",
18 | ]
19 |
20 | [tool.uv.sources]
21 | interactem-core = { path = "../core", editable = true }
22 | interactem-sfapi-models = { path = "../sfapi_models", editable = true }
23 |
24 | [dependency-groups]
25 | dev = [
26 | "pytest>=9.0.1,<10",
27 | "pytest-asyncio>=1.0.0,<2",
28 | "python-dotenv[cli]>=1.0.1,<2",
29 | ]
30 |
31 | [tool.poetry]
32 | packages = [{ include = "interactem" }]
33 |
34 | [tool.poetry.dependencies]
35 | interactem-core = {path = "../core", develop = true}
36 | interactem-sfapi-models = {path = "../sfapi_models", develop = true}
37 |
38 | [build-system]
39 | requires = ["poetry-core"]
40 | build-backend = "poetry.core.masonry.api"
41 |
42 | [tool.ruff]
43 | target-version = "py310"
44 | extend = "../../.ruff.toml"
--------------------------------------------------------------------------------
/backend/agent/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.formatOnSaveMode": "modifications",
4 | "editor.defaultFormatter": "charliermarsh.ruff",
5 | "editor.codeActionsOnSave": {
6 | "source.organizeImports": "explicit"
7 | },
8 | "workbench.colorCustomizations": {
9 | "activityBar.activeBackground": "#28693c",
10 | "activityBar.background": "#28693c",
11 | "activityBar.foreground": "#e7e7e7",
12 | "activityBar.inactiveForeground": "#e7e7e799",
13 | "activityBarBadge.background": "#35235c",
14 | "activityBarBadge.foreground": "#e7e7e7",
15 | "commandCenter.border": "#e7e7e799",
16 | "sash.hoverBorder": "#28693c",
17 | "statusBar.background": "#1a4427",
18 | "statusBar.foreground": "#e7e7e7",
19 | "statusBarItem.hoverBackground": "#28693c",
20 | "statusBarItem.remoteBackground": "#1a4427",
21 | "statusBarItem.remoteForeground": "#e7e7e7",
22 | "titleBar.activeBackground": "#1a4427",
23 | "titleBar.activeForeground": "#e7e7e7",
24 | "titleBar.inactiveBackground": "#1a442799",
25 | "titleBar.inactiveForeground": "#e7e7e799"
26 | },
27 | "peacock.remoteColor": "#1a4427"
28 | }
--------------------------------------------------------------------------------
/backend/rdma/libs/thallium/src/eng_provider.cpp:
--------------------------------------------------------------------------------
1 | #include "eng_provider.hpp"
2 |
3 | namespace interactEM {
4 |
5 | EngProvider::~EngProvider(){
6 | this->stop();
7 | }
8 |
9 | // void EngProvider::initialize(){
10 | // m_registry_.pushCxiAddress("default_agent", "default_operator", "default_cxi_address");
11 | // }
12 |
13 | // Stop the RDMA engine
14 | void EngProvider::stop() {
15 | m_rdma_pull.deregister();
16 | getEngine().pop_finalize_callback(this);
17 | }
18 |
19 | // RDMA push operation
20 | void EngProvider::rdma_pull(const tl::request& req, tl::bulk& b) {
21 | // std::cout << "Executing RDMA push operation..." << std::endl;
22 | tl::endpoint ep = req.get_endpoint();
23 | std::vector v(b.size());
24 | std::vector> segments = EngUtils::create_segments(v);
25 | tl::bulk local = getEngine().expose(segments, tl::bulk_mode::write_only);
26 | // std::cout << "Exposing bulk with size: " << b.size() << std::endl;
27 | b.on(ep) >> local;
28 | // std::cout << "Server received bulk: ";
29 | // for(auto c : v) std::cout << c;
30 | // std::cout << std::endl;
31 | // std::cout << "RDMA push operation completed successfully." << std::endl;
32 | req.respond();
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/frontend/interactEM/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { devices, defineConfig } from '@playwright/test';
2 |
3 | const port = process.env.PLAYWRIGHT_PORT ?? '5174';
4 | const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`;
5 |
6 | export default defineConfig({
7 | testDir: './tests',
8 | fullyParallel: true,
9 | forbidOnly: !!process.env.CI,
10 | retries: process.env.CI ? 2 : 0,
11 | workers: process.env.CI ? 1 : undefined,
12 | reporter:
13 | process.env.CI === 'true'
14 | ? [['line'], ['github']]
15 | : [
16 | ['line'],
17 | ['html', { open: 'on-failure', outputFolder: 'playwright-report' }],
18 | ],
19 | use: {
20 | baseURL,
21 | trace: 'on-first-retry',
22 | },
23 | webServer: {
24 | command: `npm run dev -- --host --port ${port}`,
25 | url: baseURL,
26 | reuseExistingServer: !process.env.CI,
27 | timeout: 60_000,
28 | },
29 | projects: [
30 | {
31 | name: 'chromium',
32 | use: { ...devices['Desktop Chrome'] },
33 | },
34 | {
35 | name: 'firefox',
36 | use: { ...devices['Desktop Firefox'] },
37 | },
38 | {
39 | name: 'webkit',
40 | use: { ...devices['Desktop Safari'] },
41 | },
42 | ],
43 | });
44 |
--------------------------------------------------------------------------------
/backend/orchestrator/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.formatOnSaveMode": "modifications",
4 | "editor.defaultFormatter": "charliermarsh.ruff",
5 | "editor.codeActionsOnSave": {
6 | "source.organizeImports": "explicit"
7 | },
8 | "workbench.colorCustomizations": {
9 | "activityBar.activeBackground": "#cc3100",
10 | "activityBar.background": "#cc3100",
11 | "activityBar.foreground": "#e7e7e7",
12 | "activityBar.inactiveForeground": "#e7e7e799",
13 | "activityBarBadge.background": "#00bf2e",
14 | "activityBarBadge.foreground": "#e7e7e7",
15 | "commandCenter.border": "#e7e7e799",
16 | "sash.hoverBorder": "#cc3100",
17 | "statusBar.background": "#992500",
18 | "statusBar.foreground": "#e7e7e7",
19 | "statusBarItem.hoverBackground": "#cc3100",
20 | "statusBarItem.remoteBackground": "#992500",
21 | "statusBarItem.remoteForeground": "#e7e7e7",
22 | "titleBar.activeBackground": "#992500",
23 | "titleBar.activeForeground": "#e7e7e7",
24 | "titleBar.inactiveBackground": "#99250099",
25 | "titleBar.inactiveForeground": "#e7e7e799"
26 | },
27 | "peacock.remoteColor": "#992500",
28 | }
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/nodes/table.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from "@mui/material"
2 | import type { NodeProps } from "@xyflow/react"
3 | import { useRef } from "react"
4 | import { useRuntimeOperatorStatusStyles } from "../../hooks/nats/useOperatorStatus"
5 | import { useTableData } from "../../hooks/nats/useTableData"
6 | import type { TableNodeType } from "../../types/nodes"
7 | import TableView from "../table"
8 | import Handles from "./handles"
9 |
10 | interface TableNodeBaseProps extends NodeProps {
11 | className?: string
12 | }
13 |
14 | const TableNodeBase = ({ id, data, className = "" }: TableNodeBaseProps) => {
15 | const nodeRef = useRef(null)
16 | const tablePayload = useTableData(id)
17 | const { statusClass } = useRuntimeOperatorStatusStyles(id)
18 |
19 | return (
20 |
21 |
22 |
23 | {tablePayload ? "" : "Waiting for table data..."}
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | const TableNode = TableNodeBase
31 |
32 | export default TableNode
33 |
--------------------------------------------------------------------------------
/operators/dpc/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "585e9605-088e-4190-8b21-a8338cddd39b",
3 | "image": "ghcr.io/nersc/interactem/dpc",
4 | "label": "DPC",
5 | "description": "Differential Phase Contrast",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "com",
11 | "description": "Center of Mass"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "image",
17 | "label": "Output matplotlib image",
18 | "type": "image",
19 | "description": "Differential Phase Contrast image"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "flip",
25 | "type": "bool",
26 | "description": "Whether to flip the image",
27 | "default": "true",
28 | "label": "Flip",
29 | "required": true
30 | },
31 | {
32 | "name": "theta",
33 | "type": "float",
34 | "description": "Angle to rotate the image",
35 | "default": "-9",
36 | "label": "Theta",
37 | "required": true
38 | },
39 | {
40 | "name": "reg",
41 | "type": "float",
42 | "description": "Regularization parameter",
43 | "default": "0.1",
44 | "label": "Regularization",
45 | "required": true
46 | }
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/516457e800b5_remove_items.py:
--------------------------------------------------------------------------------
1 | """Remove items
2 |
3 | Revision ID: 516457e800b5
4 | Revises: ac46c9f37d67
5 | Create Date: 2024-08-22 14:06:51.334874
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '516457e800b5'
13 | down_revision = 'ac46c9f37d67'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.drop_table('item')
21 | # ### end Alembic commands ###
22 |
23 |
24 | def downgrade():
25 | # ### commands auto generated by Alembic - please adjust! ###
26 | op.create_table('item',
27 | sa.Column('description', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
28 | sa.Column('title', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
29 | sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
30 | sa.Column('owner_id', sa.UUID(), autoincrement=False, nullable=False),
31 | sa.ForeignKeyConstraint(['owner_id'], ['user.id'], name='item_owner_id_fkey', ondelete='CASCADE'),
32 | sa.PrimaryKeyConstraint('id', name='item_pkey')
33 | )
34 | # ### end Alembic commands ###
35 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/hooks/api/useOperatorSpecs.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
2 | import { operatorsReadOperatorsOptions } from "../../client"
3 | import { zOperatorSpecs } from "../../client/generated/zod.gen"
4 |
5 | export const useOperatorSpecs = () => {
6 | const queryClient = useQueryClient()
7 | const operatorsQuery = useQuery({
8 | ...operatorsReadOperatorsOptions(),
9 | })
10 |
11 | // Mutation to handle explicit refresh
12 | const refreshMutation = useMutation({
13 | mutationFn: () => {
14 | return queryClient.fetchQuery({
15 | ...operatorsReadOperatorsOptions({
16 | query: { refresh: true },
17 | }),
18 | })
19 | },
20 | onSuccess: () => {
21 | queryClient.invalidateQueries({
22 | queryKey: operatorsReadOperatorsOptions().queryKey,
23 | })
24 | },
25 | })
26 |
27 | const response = zOperatorSpecs.safeParse(operatorsQuery.data)
28 | const operatorSpecs = response.data?.data
29 |
30 | return {
31 | operatorSpecs,
32 | isRefreshing: refreshMutation.isPending,
33 | isLoading: operatorsQuery.isLoading,
34 | refetch: () => refreshMutation.mutate(),
35 | }
36 | }
37 |
38 | export default useOperatorSpecs
39 |
--------------------------------------------------------------------------------
/operators/electron-count/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "12345678-1234-1234-1234-1234567890cd",
3 | "image": "ghcr.io/nersc/interactem/electron-count:latest",
4 | "label": "Electron Counter",
5 | "description": "Counts electrons in a frame",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "frame",
11 | "description": "Full frame"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "out",
17 | "label": "The output",
18 | "type": "sparse frame",
19 | "description": "Sparsified data"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "xray_threshold",
25 | "label": "X-ray threshold",
26 | "type": "float",
27 | "default": "2000.0",
28 | "description": "Removal of X-rays above this threshold",
29 | "required": true
30 | },
31 | {
32 | "name": "background_threshold",
33 | "label": "Background threshold",
34 | "type": "float",
35 | "default": "28.0",
36 | "description": "Removal of background below this threshold",
37 | "required": true
38 | }
39 | ],
40 | "tags": [
41 | {
42 | "value": "cpu",
43 | "description": "This operator should be run on a CPU node."
44 | }
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/pipelines/viewmodetoggle.tsx:
--------------------------------------------------------------------------------
1 | import { Edit, PlayArrow } from "@mui/icons-material"
2 | import { IconButton, Tooltip } from "@mui/material"
3 | import type React from "react"
4 | import { ViewMode, useViewModeStore } from "../../stores"
5 |
6 | export const ViewModeToggle: React.FC = () => {
7 | const { viewMode, setViewMode } = useViewModeStore()
8 |
9 | const handleToggle = () => {
10 | const newMode =
11 | viewMode === ViewMode.Composer ? ViewMode.Runtime : ViewMode.Composer
12 | setViewMode(newMode)
13 | }
14 |
15 | const isComposer = viewMode === ViewMode.Composer
16 |
17 | return (
18 |
21 |
33 | {isComposer ? (
34 |
35 | ) : (
36 |
37 | )}
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/hooks/nats/useBucket.ts:
--------------------------------------------------------------------------------
1 | import type { KV } from "@nats-io/kv"
2 | import { DrainingConnectionError } from "@nats-io/nats-core"
3 | import { useEffect, useRef, useState } from "react"
4 | import { useNats } from "../../contexts/nats"
5 |
6 | export const useBucket = (bucketName: string): KV | null => {
7 | const { keyValueManager } = useNats()
8 | const [bucket, setBucket] = useState(null)
9 | const isMounted = useRef(true)
10 |
11 | useEffect(() => {
12 | isMounted.current = true
13 |
14 | const openBucket = async () => {
15 | if (keyValueManager && !bucket) {
16 | try {
17 | const openedBucket = await keyValueManager.open(bucketName)
18 | if (isMounted.current) {
19 | setBucket(openedBucket)
20 | }
21 | } catch (error) {
22 | if (error instanceof DrainingConnectionError) {
23 | // quietly ignore if connection is draining
24 | return
25 | }
26 | console.error(`Failed to open bucket "${bucketName}":`, error)
27 | }
28 | }
29 | }
30 |
31 | openBucket()
32 |
33 | return () => {
34 | isMounted.current = false
35 | }
36 | }, [keyValueManager, bucket, bucketName])
37 |
38 | return bucket
39 | }
40 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests/api/routes/test_login.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 |
3 | from interactem.app.core.config import settings
4 |
5 |
6 | def test_get_access_token(client: TestClient) -> None:
7 | login_data = {
8 | "username": settings.FIRST_SUPERUSER_USERNAME,
9 | "password": settings.FIRST_SUPERUSER_PASSWORD,
10 | }
11 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
12 | tokens = r.json()
13 | assert r.status_code == 200
14 | assert "access_token" in tokens
15 | assert tokens["access_token"]
16 |
17 |
18 | def test_get_access_token_incorrect_password(client: TestClient) -> None:
19 | login_data = {
20 | "username": settings.FIRST_SUPERUSER_USERNAME,
21 | "password": "incorrect",
22 | }
23 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
24 | assert r.status_code == 400
25 |
26 |
27 | def test_use_access_token(
28 | client: TestClient, superuser_token_headers: dict[str, str]
29 | ) -> None:
30 | r = client.post(
31 | f"{settings.API_V1_STR}/login/test-token",
32 | headers=superuser_token_headers,
33 | )
34 | result = r.json()
35 | assert r.status_code == 200
36 | assert "username" in result
37 |
--------------------------------------------------------------------------------
/backend/operators/interactem/operators/messengers/base.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | from nats.js import JetStreamContext
4 |
5 | from interactem.core.models.messages import BytesMessage
6 | from interactem.core.models.runtime import (
7 | RuntimeInput,
8 | RuntimeOperatorID,
9 | RuntimeOutput,
10 | )
11 |
12 |
13 | class BaseMessenger(ABC):
14 | @abstractmethod
15 | def __init__(self, operator_id: RuntimeOperatorID, js: JetStreamContext):
16 | pass
17 |
18 | @property
19 | @abstractmethod
20 | def ready(self) -> bool:
21 | pass
22 |
23 | @property
24 | @abstractmethod
25 | def type(self) -> str:
26 | pass
27 |
28 | @property
29 | @abstractmethod
30 | def input_ports(self) -> list[RuntimeInput]:
31 | pass
32 |
33 | @property
34 | @abstractmethod
35 | def output_ports(self) -> list[RuntimeOutput]:
36 | pass
37 |
38 | @abstractmethod
39 | async def send(self, message: BytesMessage):
40 | pass
41 |
42 | @abstractmethod
43 | async def recv(self) -> BytesMessage | None:
44 | pass
45 |
46 | @abstractmethod
47 | async def start(self, pipeline):
48 | pass
49 |
50 | @abstractmethod
51 | async def stop(self):
52 | pass
53 |
--------------------------------------------------------------------------------
/backend/core/interactem/core/models/base.py:
--------------------------------------------------------------------------------
1 | import abc
2 | from enum import Enum
3 | from uuid import UUID
4 |
5 | IdType = UUID
6 |
7 | class PipelineDeploymentState(str, Enum):
8 | PENDING = "pending"
9 | AGENTS_ASSIGNED = "assigned_agents"
10 | FAILED_TO_START = "failed_to_start"
11 | FAILURE_ON_AGENT = "failure_on_agent"
12 | RUNNING = "running"
13 | CANCELLED = "cancelled"
14 |
15 | TERMINAL_DEPLOYMENT_STATES = [
16 | PipelineDeploymentState.CANCELLED,
17 | PipelineDeploymentState.FAILED_TO_START,
18 | ]
19 |
20 | RUNNING_DEPLOYMENT_STATES = [
21 | PipelineDeploymentState.RUNNING,
22 | ]
23 |
24 |
25 | class KvKeyMixin(abc.ABC):
26 | @abc.abstractmethod
27 | def key(self) -> str: ...
28 |
29 |
30 | class CommBackend(str, Enum):
31 | ZMQ = "zmq"
32 | MPI = "mpi"
33 | NATS = "nats"
34 |
35 |
36 | class Protocol(str, Enum):
37 | tcp = "tcp"
38 | inproc = "inproc"
39 | ipc = "ipc"
40 |
41 |
42 | class URILocation(str, Enum):
43 | operator = "operator"
44 | port = "port"
45 | agent = "agent"
46 | orchestrator = "orchestrator"
47 |
48 |
49 | class PortType(str, Enum):
50 | input = "input"
51 | output = "output"
52 |
53 |
54 | class NodeType(str, Enum):
55 | operator = "operator"
56 | port = "port"
57 |
--------------------------------------------------------------------------------
/interactEM.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "name": "Root",
5 | "path": "."
6 | },
7 | {
8 | "name": "Agent",
9 | "path": "backend/agent"
10 | },
11 | {
12 | "name": "App",
13 | "path": "backend/app"
14 | },
15 | {
16 | "name": "Callout",
17 | "path": "backend/callout"
18 | },
19 | {
20 | "name": "Core",
21 | "path": "backend/core"
22 | },
23 | {
24 | "name": "Launcher",
25 | "path": "backend/launcher"
26 | },
27 | {
28 | "name": "Metrics",
29 | "path": "backend/metrics"
30 | },
31 | {
32 | "name": "Operators",
33 | "path": "backend/operators"
34 | },
35 | {
36 | "name": "Orchestrator",
37 | "path": "backend/orchestrator"
38 | },
39 | {
40 | "name": "SFAPI Models",
41 | "path": "backend/sfapi_models"
42 | },
43 | {
44 | "name": "CLI",
45 | "path": "cli"
46 | },
47 | {
48 | "name": "Frontend",
49 | "path": "frontend"
50 | }
51 | ]
52 | }
--------------------------------------------------------------------------------
/backend/launcher/tests/test_rendering.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 | from pathlib import Path
3 |
4 | import pytest
5 | from jinja2 import Environment, PackageLoader
6 | from sfapi_client.compute import Machine
7 |
8 | from interactem.launcher.config import cfg
9 | from interactem.launcher.constants import LAUNCH_AGENT_TEMPLATE
10 | from interactem.sfapi_models import JobSubmitEvent
11 |
12 | HERE = Path(__file__).parent
13 |
14 |
15 | @pytest.fixture
16 | def expected_script() -> str:
17 | with open(HERE / "expected_script.sh") as f:
18 | return f.read()
19 |
20 |
21 | @pytest.mark.asyncio
22 | async def test_submit_rendering(expected_script: str):
23 | job_req = JobSubmitEvent(
24 | machine=Machine.perlmutter,
25 | account="test_account",
26 | qos="normal",
27 | constraint="gpu",
28 | walltime=timedelta(hours=1, minutes=30),
29 | reservation=None,
30 | num_nodes=2,
31 | )
32 |
33 | jinja_env = Environment(
34 | loader=PackageLoader("interactem.launcher"), enable_async=True
35 | )
36 | template = jinja_env.get_template(LAUNCH_AGENT_TEMPLATE)
37 |
38 | script = await template.render_async(
39 | job=job_req.model_dump(), settings=cfg.model_dump()
40 | )
41 |
42 | assert script == expected_script
43 |
--------------------------------------------------------------------------------
/backend/rdma/libs/argobots/include/abt_manager.hpp:
--------------------------------------------------------------------------------
1 |
2 | #pragma once
3 |
4 | #include
5 | #include
6 | #include
7 | #include
8 | #include
9 | #include "abt_config.hpp"
10 |
11 | namespace interactEM {
12 |
13 | namespace tl = thallium;
14 |
15 | class AbtManager {
16 |
17 | private:
18 | // AbtPool pool_; // Custom Argobots pool
19 | // AbtScheduler scheduler_; // Custom Argobots scheduler
20 | std::vector> execution_streams_;
21 | tl::managed thread_pool_;
22 | bool is_initialized_;
23 |
24 | static tl::abt* global_abt_scope_;
25 | static int instance_count_;
26 |
27 | public:
28 | AbtManager() : is_initialized_(false) {}
29 | AbtManager(const AbtConfig& config) {}
30 | ~AbtManager();
31 |
32 | AbtManager(const AbtManager& other) = delete;
33 | AbtManager& operator=(const AbtManager& other) = delete;
34 |
35 | AbtManager(AbtManager&& other) = default;
36 | AbtManager& operator=(AbtManager&& other) = default;
37 |
38 | void initialize();
39 | void finalize();
40 | tl::pool& getPool() {return *thread_pool_;}
41 | };
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/operators/diffraction-pattern-accumulator/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "dedeba48-cb84-48dc-b6ca-4c11d94e9375",
3 | "image": "ghcr.io/nersc/interactem/diffraction-pattern-accumulator:latest",
4 | "label": "Diffraction Pattern Accumulator",
5 | "description": "Accumulates diffraction patterns from sparse arrays.",
6 | "inputs": [
7 | {
8 | "name": "counted_data",
9 | "label": "Counted Data",
10 | "type": "sparse_frame",
11 | "description": "Sparse frame of counted data"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "image",
17 | "label": "Output Image",
18 | "type": "image",
19 | "description": "Dense image"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "update_frequency",
25 | "label": "Update Frequency",
26 | "type": "int",
27 | "default": "100",
28 | "description": "The number of frames to accumulate before sending out a frame.",
29 | "required": true
30 | },
31 | {
32 | "name": "max_concurrent_scans",
33 | "label": "Max Concurrent Scans",
34 | "type": "int",
35 | "default": "1",
36 | "description": "Maximum number of scans to keep in memory simultaneously. Oldest scans are evicted when this limit is exceeded.",
37 | "required": false
38 | }
39 | ]
40 | }
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # interactEM Documentation
2 |
3 | This directory contains the documentation for interactEM, built with Sphinx and the Furo theme.
4 |
5 | ## Building the Documentation
6 |
7 | Build the HTML documentation:
8 |
9 | ```bash
10 | make build
11 | ```
12 |
13 | The built documentation will be in `_build/html/`.
14 |
15 | ## Development
16 |
17 | For live auto-rebuilding during development:
18 |
19 | ```bash
20 | make autobuild
21 | ```
22 |
23 | This will start a local server and automatically rebuild the docs when you make changes.
24 |
25 | ### Syncing repository READMEs
26 |
27 | The Markdown files in `docs/source/` are the source of truth. Run the helper to regenerate the top-level README files after editing docs:
28 |
29 | ```bash
30 | make sync-readmes
31 | ```
32 |
33 | The script strips ` … ` sections so Sphinx-only snippets stay out of the GitHub READMEs.
34 |
35 | ## Structure
36 |
37 | - `source/` - Documentation source files (RST and Markdown)
38 | - `source/_static/` - Static assets (CSS, JS, images)
39 | - `source/_templates/` - Custom Sphinx templates
40 | - `source/conf.py` - Sphinx configuration
41 |
42 | ## Theme
43 |
44 | This documentation uses the Furo theme with custom styling inspired by the [iceoryx2-book](https://github.com/ekxide/iceoryx2-book) project.
45 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/nodes/image.tsx:
--------------------------------------------------------------------------------
1 | import type { NodeProps } from "@xyflow/react"
2 | import { useRef } from "react"
3 | import { useImage } from "../../hooks/nats/useImage"
4 | import { useRuntimeOperatorStatusStyles } from "../../hooks/nats/useOperatorStatus"
5 | import type { ImageNodeType } from "../../types/nodes"
6 | import Image from "../image"
7 | import Handles from "./handles"
8 | import OperatorToolbar from "./toolbar"
9 |
10 | interface ImageNodeBaseProps extends NodeProps {
11 | className?: string
12 | }
13 |
14 | const ImageNodeBase = ({ id, data, className = "" }: ImageNodeBaseProps) => {
15 | const nodeRef = useRef(null)
16 | const imageData = useImage(id)
17 | const { statusClass } = useRuntimeOperatorStatusStyles(id)
18 |
19 | // TODO: the data containing the positions causes a re-render of the node.
20 |
21 | return (
22 |
23 |
24 |
25 |
31 |
32 | )
33 | }
34 |
35 | const ImageNode = ImageNodeBase
36 |
37 | export default ImageNode
38 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/logs/agentdialog.tsx:
--------------------------------------------------------------------------------
1 | import CloseIcon from "@mui/icons-material/Close"
2 | import { Box, Dialog, DialogContent, DialogTitle } from "@mui/material"
3 | import React from "react"
4 | import { useAgentLogs } from "../../hooks/nats/useAgentLogs"
5 | import LogsList from "./list"
6 | import { CloseDialogButton, LogsPanel } from "./styles"
7 |
8 | interface AgentLogsDialogProps {
9 | open: boolean
10 | onClose: () => void
11 | agentId: string
12 | agentLabel: string
13 | }
14 |
15 | const AgentLogsDialog: React.FC = ({
16 | open,
17 | onClose,
18 | agentId,
19 | agentLabel,
20 | }) => {
21 | const { logs } = useAgentLogs({
22 | id: agentId,
23 | })
24 |
25 | return (
26 |
41 | )
42 | }
43 |
44 | export default React.memo(AgentLogsDialog)
45 |
--------------------------------------------------------------------------------
/frontend/interactEM/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from "path"
2 | import react from "@vitejs/plugin-react"
3 | import { visualizer } from "rollup-plugin-visualizer"
4 | import { defineConfig } from "vite"
5 | import dts from "vite-plugin-dts"
6 |
7 | const __dirname = resolve()
8 |
9 | // https://vitejs.dev/config/
10 | export default defineConfig({
11 | server: {
12 | strictPort: true,
13 | port: 5173,
14 | },
15 | plugins: [
16 | react(),
17 | dts({
18 | rollupTypes: true,
19 | tsconfigPath: resolve(__dirname, "tsconfig.json"),
20 | }),
21 | visualizer({ open: false, filename: "bundle-visualization.html" }),
22 | ],
23 | build: {
24 | lib: {
25 | entry: resolve(__dirname, "src/index.ts"),
26 | name: "InteractEM",
27 | fileName: "interactem",
28 | },
29 | rollupOptions: {
30 | // this is critical for react-query. TODO: figure out why...
31 | // https://github.com/TanStack/query/issues/7927
32 | // potentially explore https://www.npmjs.com/package/@tanstack/config
33 | external: ["react", "react-dom", "@tanstack/react-query"],
34 | output: {
35 | globals: {
36 | react: "React",
37 | "react-dom": "ReactDOM",
38 | "@tanstack/react-query": "ReactQuery",
39 | },
40 | },
41 | treeshake: true,
42 | },
43 | },
44 | })
45 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/notificationstoast.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AckPolicy,
3 | DeliverPolicy,
4 | type JsMsg,
5 | ReplayPolicy,
6 | } from "@nats-io/jetstream"
7 | import { useCallback, useMemo } from "react"
8 | import { type TypeOptions, toast } from "react-toastify"
9 | import {
10 | STREAM_NOTIFICATIONS,
11 | SUBJECT_NOTIFICATIONS_ERRORS,
12 | } from "../constants/nats"
13 | import { useConsumeMessages } from "../hooks/nats/useConsumeMessages"
14 | import { useConsumer } from "../hooks/nats/useConsumer"
15 |
16 | export default function NotificationsToast() {
17 | const config = useMemo(
18 | () => ({
19 | filter_subjects: [`${STREAM_NOTIFICATIONS}.>`],
20 | ack_policy: AckPolicy.All,
21 | deliver_policy: DeliverPolicy.New,
22 | replay_policy: ReplayPolicy.Instant,
23 | }),
24 | [],
25 | )
26 | const consumer = useConsumer({
27 | stream: `${STREAM_NOTIFICATIONS}`,
28 | config: config,
29 | })
30 |
31 | const handleMessage = useCallback(async (m: JsMsg) => {
32 | let toastType: TypeOptions = "info"
33 |
34 | if (m.subject === `${SUBJECT_NOTIFICATIONS_ERRORS}`) {
35 | toastType = "error"
36 | }
37 |
38 | const notification = m.string()
39 | toast(notification, { type: toastType })
40 | }, [])
41 |
42 | useConsumeMessages({ consumer, handleMessage })
43 |
44 | return null
45 | }
46 |
--------------------------------------------------------------------------------
/operators/beam-compensation/operator.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "10010008-1234-1234-1234-1000000890ab",
3 | "image": "ghcr.io/nersc/interactem/beam-compensation",
4 | "label": "Beam Compensation (vacuum scan)",
5 | "description": "Compensates for beam movement inside of a frame using vacuum background",
6 | "inputs": [
7 | {
8 | "name": "in",
9 | "label": "The input",
10 | "type": "partial frame",
11 | "description": "Partial frame"
12 | }
13 | ],
14 | "outputs": [
15 | {
16 | "name": "out",
17 | "label": "The output",
18 | "type": "frame",
19 | "description": "Full frame"
20 | }
21 | ],
22 | "parameters": [
23 | {
24 | "name": "offsets.emd",
25 | "label": "Offsets EMD file",
26 | "type": "mount",
27 | "default": "~/FOURD_241002_0852_20132_00714_offsets.emd",
28 | "description": "Offsets EMD file -- you should create one of these for data the day of an experiment",
29 | "required": true
30 | },
31 | {
32 | "name": "method",
33 | "label": "Background subtr. method",
34 | "type": "str-enum",
35 | "default": "interp",
36 | "description": "Method to use for background subtraction",
37 | "options": ["plane", "interp"],
38 | "required": true
39 | }
40 | ],
41 | "parallel_config": {
42 | "type": "embarrassing"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/operators/distiller-streaming/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "distiller_streaming"
3 | version = "0.0.1"
4 | description = ""
5 | authors = [
6 | {name = "swelborn", email = "swelborn@lbl.gov"}
7 | ]
8 | readme = "README.md"
9 | requires-python = ">=3.10"
10 | dependencies = [
11 | "pyzmq>=27.0.1,<28.0.0",
12 | "msgpack>=1.1.0,<2.0.0",
13 | "pandas>=2.3.1,<3.0.0",
14 | "pydantic>=2.11.7,<3.0.0",
15 | "stempy>=3.4.0,<4.0.0",
16 | "numpy>=2.2.6,<2.3.0",
17 | "msgspec>=0.19.0,<0.20.0",
18 | "interactem-core",
19 | "interactem-operators",
20 | ]
21 |
22 | [dependency-groups]
23 | dev = [
24 | "pytest>=9.0.1,<10",
25 | ]
26 |
27 | [build-system]
28 | requires = ["poetry-core>=2.0.0,<3.0.0"]
29 | build-backend = "poetry.core.masonry.api"
30 |
31 | [tool.uv.sources]
32 | interactem-core = { path = "../../backend/core", editable = true }
33 | interactem-operators = { path = "../../backend/operators", editable = true }
34 |
35 | [tool.poetry.dependencies]
36 | interactem-core = {path = "../../backend/core", develop = true}
37 | interactem-operators = {path = "../../backend/operators", develop = true}
38 |
39 | [tool.ruff]
40 | target-version = "py310"
41 | extend = "../../.ruff.toml"
42 | exclude = []
43 |
44 |
45 | [tool.ruff.lint]
46 | exclude = []
47 |
48 | [tool.ruff.lint.isort]
49 | known-first-party = ["distiller_streaming", "interactem"]
50 |
--------------------------------------------------------------------------------
/backend/core/interactem/core/models/messages.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from enum import Enum
3 | from typing import Any
4 |
5 | from pydantic import BaseModel, Field
6 |
7 | from interactem.core.models.base import IdType
8 |
9 |
10 | class TrackingMetadataBase(BaseModel):
11 | id: IdType
12 |
13 |
14 | class InputPortTrackingMetadata(TrackingMetadataBase):
15 | time_after_header_validate: datetime
16 |
17 |
18 | class OutputPortTrackingMetadata(TrackingMetadataBase):
19 | time_before_send: datetime
20 |
21 |
22 | class OperatorTrackingMetadata(TrackingMetadataBase):
23 | time_before_operate: datetime
24 | time_after_operate: datetime
25 |
26 | class TrackingMetadatas(BaseModel):
27 | metadatas: list[
28 | OperatorTrackingMetadata
29 | | OutputPortTrackingMetadata
30 | | InputPortTrackingMetadata
31 | ] = Field(default_factory=list)
32 |
33 |
34 | class MessageSubject(str, Enum):
35 | BYTES = "bytes"
36 | SHM = "shm"
37 |
38 |
39 | class MessageHeader(BaseModel):
40 | subject: MessageSubject
41 | meta: bytes | dict[str, Any] = b"{}"
42 | tracking: TrackingMetadatas | None = None
43 |
44 | class BaseMessage(BaseModel):
45 | header: MessageHeader
46 |
47 |
48 | class BytesMessage(BaseMessage):
49 | data: bytes
50 |
51 |
52 | class ShmMessage(BaseMessage):
53 | shm_meta: dict[str, Any] = {}
54 |
--------------------------------------------------------------------------------
/scripts/ensure-nats-credentials.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Ensure credential files exist as files (not directories) before docker-compose mount
3 | # This prevents Docker from creating directories during bind mounting of non-existent files
4 |
5 | set -euo pipefail
6 |
7 | # Find the git repository root
8 | GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
9 | echo "Error: Not in a git repository" >&2
10 | exit 1
11 | }
12 |
13 | CREDS_DIR="$GIT_ROOT/conf/nats-conf/out_jwt"
14 |
15 | # Credential files that are bind-mounted in docker-compose
16 | credential_files=(
17 | "backend.creds"
18 | "frontend.creds"
19 | "operator.creds"
20 | "callout.creds"
21 | )
22 |
23 | # Create parent directory if it doesn't exist
24 | mkdir -p "$CREDS_DIR"
25 |
26 | # Check and fix credential files
27 | for file in "${credential_files[@]}"; do
28 | filepath="$CREDS_DIR/$file"
29 |
30 | if [ -d "$filepath" ]; then
31 | # If it's a directory, remove it and create as file
32 | echo "⚠ Found directory instead of file: $filepath, fixing..."
33 | rm -rf "$filepath"
34 | touch "$filepath"
35 | echo "✓ Converted directory to file: $filepath"
36 | elif [ ! -e "$filepath" ]; then
37 | # If it doesn't exist, create as empty file
38 | touch "$filepath"
39 | echo "✓ Created placeholder file: $filepath"
40 | fi
41 | done
42 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/components/agents/chip.tsx:
--------------------------------------------------------------------------------
1 | import Chip from "@mui/material/Chip"
2 | import Tooltip from "@mui/material/Tooltip"
3 | import { useState } from "react"
4 | import type { AgentVal } from "../../types/gen"
5 | import { getAgentStatusColor } from "../../utils/statusColor"
6 | import AgentLogsDialog from "../logs/agentdialog"
7 | import { StatusDot } from "../statusdot"
8 | import AgentTooltip from "./tooltip"
9 |
10 | interface AgentChipProps {
11 | agent: AgentVal
12 | }
13 |
14 | export default function AgentChip({ agent }: AgentChipProps) {
15 | const [open, setOpen] = useState(false)
16 | const shortId = agent.uri.id.substring(0, 6)
17 | const displayName = agent.name?.trim() ? agent.name : shortId
18 |
19 | return (
20 | <>
21 | } arrow>
22 | }
24 | label={displayName}
25 | color={getAgentStatusColor(agent.status)}
26 | variant="outlined"
27 | onClick={() => setOpen(true)}
28 | clickable
29 | sx={{ fontWeight: 500, fontSize: "1rem" }}
30 | />
31 |
32 |
33 | setOpen(false)}
36 | agentId={agent.uri.id}
37 | agentLabel={displayName}
38 | />
39 | >
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/types/agent.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 | import {
3 | AgentStatus,
4 | type AgentVal,
5 | CommBackend,
6 | type ErrorMessage,
7 | type URI,
8 | URILocation,
9 | } from "./gen"
10 |
11 | const zURI = z.object({
12 | id: z.string(),
13 | location: z.nativeEnum(URILocation),
14 | hostname: z.string(),
15 | comm_backend: z.nativeEnum(CommBackend),
16 | query: z.record(z.string(), z.array(z.string())).optional(),
17 | }) satisfies z.ZodType
18 |
19 | // For AgentErrorMessage, handle manually due to index signature
20 | const zErrorMessage = z.object({
21 | message: z.string(),
22 | timestamp: z.number(),
23 | }) satisfies z.ZodType
24 |
25 | export const AgentValSchema = z.object({
26 | name: z.string().nullable().optional(),
27 | uri: zURI,
28 | status: z.nativeEnum(AgentStatus),
29 | status_message: z.string().nullable().optional(),
30 | tags: z.array(z.string()).optional(),
31 | networks: z.array(z.string()),
32 | pipeline_id: z.string().nullable().optional(),
33 | operator_assignments: z.array(z.string()).nullable().optional(),
34 | uptime: z.number(),
35 | error_messages: z.array(zErrorMessage).optional(),
36 | }) satisfies z.ZodType
37 |
38 | // Re-export this because AgentVal is an interface and we need this for
39 | // the AgentNode type
40 | export type AgentValType = z.infer
41 |
--------------------------------------------------------------------------------
/scripts/setup-podman-socket.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Script to update PODMAN_SERVICE_URI in all .env files with the actual podman socket path
4 |
5 | set -euo pipefail
6 |
7 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8 | REPO_ROOT="$(dirname "$SCRIPT_DIR")"
9 |
10 | echo "Setting up PODMAN_SERVICE_URI in .env files..."
11 |
12 | # Get the podman socket path in the unix:// format
13 | PODMAN_SERVICE_URI=$("$SCRIPT_DIR/podman-socket-path.sh")
14 |
15 | if [ -z "$PODMAN_SERVICE_URI" ]; then
16 | echo "Error: Could not determine podman socket URI" >&2
17 | exit 1
18 | fi
19 |
20 | echo "Using podman socket URI: $PODMAN_SERVICE_URI"
21 | echo ""
22 |
23 | # Find all .env files and update PODMAN_SERVICE_URI
24 | updated_count=0
25 |
26 | while IFS= read -r -d '' env_file; do
27 | if grep -q "PODMAN_SERVICE_URI=" "$env_file"; then
28 | # Use sed to replace the PODMAN_SERVICE_URI line
29 | # This handles both placeholder values and existing values
30 | sed -i.bak "s|^PODMAN_SERVICE_URI=.*|PODMAN_SERVICE_URI=$PODMAN_SERVICE_URI|" "$env_file"
31 | echo "✓ Updated: $env_file"
32 | # Clean up backup file
33 | rm -f "${env_file}.bak"
34 | updated_count=$((updated_count + 1))
35 | fi
36 | done < <(find "$REPO_ROOT" -name ".env" -type f -print0)
37 |
38 | echo ""
39 | echo "Summary:"
40 | echo " Updated: $updated_count .env file(s)"
41 |
--------------------------------------------------------------------------------
/frontend/interactEM/src/contexts/nats/agentstatus.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode, createContext, useContext, useMemo } from "react"
2 | import { AGENTS, BUCKET_STATUS } from "../../constants/nats"
3 | import { useBucketWatch } from "../../hooks/nats/useBucketWatch"
4 | import { AgentValSchema } from "../../types/agent"
5 | import type { AgentVal } from "../../types/gen"
6 |
7 | interface AgentStatusContextType {
8 | agents: AgentVal[]
9 | agentsLoading: boolean
10 | agentsError: string | null
11 | }
12 |
13 | const AgentStatusContext = createContext({
14 | agents: [],
15 | agentsLoading: true,
16 | agentsError: null,
17 | })
18 |
19 | export const AgentStatusProvider: React.FC<{ children: ReactNode }> = ({
20 | children,
21 | }) => {
22 | const {
23 | items: agents,
24 | isLoading: agentsLoading,
25 | error: agentsError,
26 | } = useBucketWatch({
27 | bucketName: BUCKET_STATUS,
28 | schema: AgentValSchema,
29 | keyFilter: `${AGENTS}.>`,
30 | stripPrefix: AGENTS,
31 | })
32 |
33 | const contextValue = useMemo(
34 | () => ({ agents, agentsLoading, agentsError }),
35 | [agents, agentsLoading, agentsError],
36 | )
37 |
38 | return (
39 |
40 | {children}
41 |
42 | )
43 | }
44 |
45 | export const useAgentStatusContext = () => useContext(AgentStatusContext)
46 |
--------------------------------------------------------------------------------
/docs/source/launch-agent.md:
--------------------------------------------------------------------------------
1 | # Launch an agent
2 |
3 | The agent is the process that starts operator containers, coordinates their lifecycle, and talks to the platform over NATS. You need at least one running agent before operators can launch.
4 |
5 | ## Prerequisites
6 |
7 | Have a Python environment manager available (e.g., [`poetry`](https://python-poetry.org/docs/) or [`uv`](https://github.com/astral-sh/uv)).
8 |
9 | ## Configure the environment
10 |
11 | You should already have run `make setup` in the repository root to create a base `.env`. Then:
12 |
13 | ```bash
14 | cd backend/agent
15 | ```
16 |
17 | - Copy `.env.example` if you have not already, and update values as needed.
18 | - Set the agent's display name in `AGENT_NAME`:
19 |
20 | ```bash
21 | AGENT_NAME=SecretAgentMan # change to how you want it to display in the frontend
22 | ```
23 |
24 | - If you want to target specific resources, set tags:
25 |
26 | ```bash
27 | AGENT_TAGS='["ncem-4dcamera","gpu"]'
28 | ```
29 |
30 | ## Install and run
31 |
32 | Install dependencies with your preferred tool:
33 |
34 | ```bash
35 | poetry install
36 | ```
37 |
38 | or
39 |
40 | ```bash
41 | uv sync
42 | ```
43 |
44 | Then activate your virtual environment and start the agent from the directory containing your `.env`:
45 |
46 | ```bash
47 | cd backend/agent
48 | interactem-agent
49 | ```
50 |
51 | or with `uv`:
52 |
53 | ```bash
54 | cd backend/agent
55 | uv run interactem-agent
56 | ```
57 |
--------------------------------------------------------------------------------
/backend/agent/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "interactem-agent"
3 | version = "0.1.0"
4 | description = "Agent for interactem"
5 | readme = "README.md"
6 | authors = [
7 | {name = "Sam Welborn", email = "swelborn@lbl.gov"},
8 | {name = "Chris Harris", email = "cjh@lbl.gov"}
9 | ]
10 | requires-python = ">=3.10"
11 | dependencies = [
12 | "interactem-core",
13 | "podman>=5.0.0,<6",
14 | "pydantic-settings>=2.4,<3",
15 | "nkeys>=0.2.1,<0.3",
16 | "aiohttp>=3.11.12,<4",
17 | "netifaces2>=0.0.22,<0.1",
18 | "stamina>=25.1.0,<26",
19 | "jinja2>=3.1.6,<4",
20 | ]
21 |
22 | [project.optional-dependencies]
23 | hpc = [
24 | "podman-hpc @ git+https://github.com/cjh1/podman-hpc.git@d01773a8ae14489af7654d799d9738a2c5629248",
25 | "podman-hpc-py @ git+https://github.com/cjh1/podman-hpc-py.git@577e467ab5bf10840df81234e477a3ebbd436f7e",
26 | ]
27 |
28 |
29 | [project.scripts]
30 | interactem-agent = "interactem.agent.entrypoint:entrypoint"
31 |
32 | [tool.uv.sources]
33 | interactem-core = { path = "../core", editable = true }
34 |
35 |
36 | [tool.poetry]
37 | packages = [{ include = "interactem" }]
38 |
39 | [tool.poetry.dependencies]
40 | interactem-core = {path = "../core", develop = true}
41 |
42 | [build-system]
43 | requires = ["poetry-core"]
44 | build-backend = "poetry.core.masonry.api"
45 |
46 | [tool.ruff]
47 | target-version = "py310"
48 | extend = "../../.ruff.toml"
49 | extend-exclude = ["thirdparty/"]
50 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/alembic/versions/ae4ed8e4c67b_current_revision_id.py:
--------------------------------------------------------------------------------
1 | """current_revision_id
2 |
3 | Revision ID: ae4ed8e4c67b
4 | Revises: 60a2bc7c4aea
5 | Create Date: 2025-04-19 18:06:17.099537
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlmodel.sql.sqltypes
11 |
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = 'ae4ed8e4c67b'
15 | down_revision = '60a2bc7c4aea'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade():
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.add_column('pipeline', sa.Column('current_revision_id', sa.Integer(), nullable=True))
23 |
24 | # Update existing NULL values to 0
25 | op.execute('UPDATE pipeline SET current_revision_id = 0 WHERE current_revision_id IS NULL')
26 |
27 | # Alter the column to be non-nullable
28 | op.alter_column('pipeline', 'current_revision_id',
29 | existing_type=sa.Integer(),
30 | nullable=False)
31 |
32 | op.create_index(op.f('ix_pipeline_current_revision_id'), 'pipeline', ['current_revision_id'], unique=False)
33 | # ### end Alembic commands ###
34 |
35 |
36 | def downgrade():
37 | # ### commands auto generated by Alembic - please adjust! ###
38 | op.drop_index(op.f('ix_pipeline_current_revision_id'), table_name='pipeline')
39 | op.drop_column('pipeline', 'current_revision_id')
40 | # ### end Alembic commands ###
41 |
--------------------------------------------------------------------------------
/backend/app/interactem/app/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Generator
2 |
3 | import pytest
4 | from fastapi.testclient import TestClient
5 | from sqlmodel import Session, delete
6 |
7 | from interactem.app.core.db import engine, init_db
8 | from interactem.app.main import app
9 | from interactem.app.models import Pipeline, User
10 | from interactem.app.tests.utils.user import authentication_token_from_username
11 | from interactem.app.tests.utils.utils import get_superuser_token_headers
12 |
13 |
14 | @pytest.fixture(scope="session", autouse=True)
15 | def db() -> Generator[Session, None, None]:
16 | with Session(engine) as session:
17 | init_db(session)
18 | yield session
19 | statement = delete(Pipeline)
20 | session.execute(statement)
21 | statement = delete(User)
22 | session.execute(statement)
23 | session.commit()
24 |
25 |
26 | @pytest.fixture(scope="module")
27 | def client() -> Generator[TestClient, None, None]:
28 | with TestClient(app) as c:
29 | yield c
30 |
31 |
32 | @pytest.fixture(scope="module")
33 | def superuser_token_headers(client: TestClient) -> dict[str, str]:
34 | return get_superuser_token_headers(client)
35 |
36 |
37 | @pytest.fixture(scope="module")
38 | def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
39 | return authentication_token_from_username(
40 | client=client, username="test_user", db=db
41 | )
42 |
--------------------------------------------------------------------------------