From d27c0f416e897977d3e04d00ec6d1ff9eff48953 Mon Sep 17 00:00:00 2001 From: Noppanat Wadlom Date: Wed, 20 May 2026 14:51:29 +0800 Subject: [PATCH 1/2] docs: fix flowmesh-hook documentation Signed-off-by: Noppanat Wadlom --- hook/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hook/README.md b/hook/README.md index 2095066..2e60def 100644 --- a/hook/README.md +++ b/hook/README.md @@ -3,7 +3,9 @@ FlowMesh-specific plugin extension surface. Carries the pieces FlowMesh adds on top of [`lumid-hooks`](https://github.com/mlsys-io/lumid.hooks): -- `HookBindings` — concrete dataclass with FlowMesh's six fields (the five +- `HookBindings` — runtime-checkable Protocol extending the shared one with + `supplier_resolvers`, used by the server's plugin gate. +- `BaseBindings` — frozen dataclass with FlowMesh's six fields (the five shared from `lumid-hooks` plus `supplier_resolvers`). - `ResourceKind` / `ResourceAction` — FlowMesh resource and action enums. - `SupplierResolver` / `WorkerView` — supplier attribution at dispatch time. From d47a4f5b4648ee21b08b7f78961471dcfb4c78a2 Mon Sep 17 00:00:00 2001 From: Noppanat Wadlom Date: Wed, 20 May 2026 17:51:55 +0800 Subject: [PATCH 2/2] feat(stack): add FLOWMESH_PLUGIN_DATA_DIR for plugin writable storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FLOWMESH_PLUGIN_DIR is deliberately read-only — it's the code mount — so plugins that need persistence (a SQLite ACL, a cache file) currently have to ask each operator to add a custom bind via docker-compose.override.yml, which is brittle. Add a second mount at /app/plugin-data driven by FLOWMESH_PLUGIN_DATA_DIR. A path value (the default, `./plugin-data`) is a host bind, auto-created on stack up; a bare name is an external Docker volume of that name, which the operator precreates and owns the lifecycle of. The CLI discriminates path vs bare name in apply_plugin_data_env and routes the value to either the service mount directly (path mode) or an internal FLOWMESH_PLUGIN_DATA_VOLUME parameter consumed by a parameterized external volume declaration (volume mode). Signed-off-by: Noppanat Wadlom --- .../src/flowmesh_cli_stack/assets/.env.example | 12 ++++++++---- .../src/flowmesh_cli_stack/assets/compose.yml | 4 ++++ cli/stack/src/flowmesh_cli_stack/env_schema.py | 18 ++++++++++++++---- cli/stack/src/flowmesh_cli_stack/stack.py | 2 ++ cli/stack/src/flowmesh_cli_stack/utils.py | 16 ++++++++++++++++ docs/ENV.md | 1 + docs/PLUGINS.md | 5 +++++ 7 files changed, 50 insertions(+), 8 deletions(-) diff --git a/cli/stack/src/flowmesh_cli_stack/assets/.env.example b/cli/stack/src/flowmesh_cli_stack/assets/.env.example index c34520b..d1c56c2 100644 --- a/cli/stack/src/flowmesh_cli_stack/assets/.env.example +++ b/cli/stack/src/flowmesh_cli_stack/assets/.env.example @@ -173,11 +173,15 @@ NEBULA_API_TOKEN= # ==== External Plugins ==== # Plugins are Python packages dropped under FLOWMESH_PLUGIN_DIR -# (host-mounted to /app/plugins on the server) and selected by -# FLOWMESH_PLUGINS as a comma-separated list of top-level module -# names. Each named module must expose `install()` returning a -# `HookBindings`. Leave both empty unless you ship a plugin. +# (read-only at /app/plugins) and selected by FLOWMESH_PLUGINS as +# a comma-separated list of top-level module names. Each must +# expose `install()` returning a `HookBindings`. +# FLOWMESH_PLUGIN_DATA_DIR is writable at /app/plugin-data for +# plugin state. Leave all empty unless you ship a plugin. FLOWMESH_PLUGIN_DIR=./plugins +# A path (`./x`, `/abs/x`) -> host bind-mount (auto-created). +# A bare name -> external Docker volume of that name. +FLOWMESH_PLUGIN_DATA_DIR=./plugin-data FLOWMESH_PLUGINS= # ==== Agent Executor (youtu-agent / utu) ==== diff --git a/cli/stack/src/flowmesh_cli_stack/assets/compose.yml b/cli/stack/src/flowmesh_cli_stack/assets/compose.yml index 9bc07df..a1df7d7 100644 --- a/cli/stack/src/flowmesh_cli_stack/assets/compose.yml +++ b/cli/stack/src/flowmesh_cli_stack/assets/compose.yml @@ -166,6 +166,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock - ${SERVER_WORKER_CONFIG:-./configs/worker_config.yaml}:/etc/flowmesh/worker_config.yaml:ro - ${FLOWMESH_PLUGIN_DIR:-./plugins}:/app/plugins:ro + - ${FLOWMESH_PLUGIN_DATA_DIR:-./plugin-data}:/app/plugin-data - ${REDIS_TLS_DIR:-./secrets/tls/redis}:/etc/ssl/redis:ro - ${SERVER_TLS_DIR:-./secrets/tls/server}:/etc/ssl/server:ro restart: unless-stopped @@ -199,3 +200,6 @@ volumes: name: ${FLOWMESH_STACK_SLUG:-flowmesh_node}_metrics flowmesh_server_logs: name: ${FLOWMESH_STACK_SLUG:-flowmesh_node}_server_logs + flowmesh_plugin_data: + external: true + name: ${FLOWMESH_PLUGIN_DATA_VOLUME:-flowmesh_plugin_data} diff --git a/cli/stack/src/flowmesh_cli_stack/env_schema.py b/cli/stack/src/flowmesh_cli_stack/env_schema.py index 3064402..eef8366 100644 --- a/cli/stack/src/flowmesh_cli_stack/env_schema.py +++ b/cli/stack/src/flowmesh_cli_stack/env_schema.py @@ -530,10 +530,11 @@ title="External Plugins", description=[ "Plugins are Python packages dropped under FLOWMESH_PLUGIN_DIR ", - "(host-mounted to /app/plugins on the server) and selected by ", - "FLOWMESH_PLUGINS as a comma-separated list of top-level module ", - "names. Each named module must expose `install()` returning a ", - "`HookBindings`. Leave both empty unless you ship a plugin.", + "(read-only at /app/plugins) and selected by FLOWMESH_PLUGINS as ", + "a comma-separated list of top-level module names. Each must ", + "expose `install()` returning a `HookBindings`. ", + "FLOWMESH_PLUGIN_DATA_DIR is writable at /app/plugin-data for ", + "plugin state. Leave all empty unless you ship a plugin.", ], vars=[ EnvVar( @@ -543,6 +544,15 @@ use_default=True, ensure_path="create", ), + EnvVar( + "FLOWMESH_PLUGIN_DATA_DIR", + "./plugin-data", + use_default=True, + description=[ + "A path (`./x`, `/abs/x`) -> host bind-mount (auto-created).", + "A bare name -> external Docker volume of that name.", + ], + ), EnvVar("FLOWMESH_PLUGINS", ""), ], ), diff --git a/cli/stack/src/flowmesh_cli_stack/stack.py b/cli/stack/src/flowmesh_cli_stack/stack.py index c8a075b..630b32b 100644 --- a/cli/stack/src/flowmesh_cli_stack/stack.py +++ b/cli/stack/src/flowmesh_cli_stack/stack.py @@ -35,6 +35,7 @@ from .utils import ( DEFAULT_ENV_FILE, STACK_PATH_KEYS, + apply_plugin_data_env, apply_stack_resource_env, ensure_deploy_paths, parse_node_role, @@ -58,6 +59,7 @@ def _load(env_file: Path) -> None: except ValueError as exc: logging.error(str(exc)) raise typer.Exit(code=1) + apply_plugin_data_env(Path.cwd()) return DockerComposeStack( compose_file=stack_compose_file(), diff --git a/cli/stack/src/flowmesh_cli_stack/utils.py b/cli/stack/src/flowmesh_cli_stack/utils.py index 5acb487..1fac0dd 100644 --- a/cli/stack/src/flowmesh_cli_stack/utils.py +++ b/cli/stack/src/flowmesh_cli_stack/utils.py @@ -57,6 +57,22 @@ def apply_stack_resource_env() -> None: os.environ[WORKER_RESULTS_DIR_ENV] = results_volume +_PLUGIN_DATA_PATH_PREFIXES = ("/", "./", "../", "~/", "~") +_PLUGIN_DATA_ALIAS = "flowmesh_plugin_data" +_PLUGIN_DATA_DEFAULT = "./plugin-data" + + +def apply_plugin_data_env(base_dir: Path) -> None: + raw = os.environ.get("FLOWMESH_PLUGIN_DATA_DIR", "").strip() + if not raw or raw.startswith(_PLUGIN_DATA_PATH_PREFIXES): + resolved = resolve_path(raw, default=_PLUGIN_DATA_DEFAULT, base_dir=base_dir) + ensure_dir(resolved) + os.environ["FLOWMESH_PLUGIN_DATA_DIR"] = str(resolved) + else: + os.environ["FLOWMESH_PLUGIN_DATA_VOLUME"] = raw + os.environ["FLOWMESH_PLUGIN_DATA_DIR"] = _PLUGIN_DATA_ALIAS + + def stack_compose_file() -> Path: return asset_path("flowmesh_cli_stack.assets", "compose.yml") diff --git a/docs/ENV.md b/docs/ENV.md index 9bde44e..11da745 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -35,6 +35,7 @@ listed here is in `.env.example`. | `ENABLE_WORKER_WATCHDOG` | `true` | Worker death detection | | `WORKER_DEATH_GRACE_SEC` | `60` | Grace period before marking dead | | `FLOWMESH_PLUGINS` | – | Comma-separated plugin module names | +| `FLOWMESH_PLUGIN_DATA_DIR` | `./plugin-data` | Writable mount at `/app/plugin-data` for plugin state. A path -> host bind-mount (auto-created); a bare name -> external Docker volume of that name. | | `SERVER_CUDA_PROBE_IMAGE` | `nvidia/cuda:12.9.1-base-ubuntu24.04` | CUDA image the server runs briefly to query local GPU names/indices | | `DOCKER_GPU_RUNTIME` | nvidia | Optional Docker runtime name for GPU probe/worker containers; leave empty unless the host requires a named runtime such as `nvidia` | | `FLOWMESH_API_KEY` | – | Forwarded to spawned workers as their server-callback bearer | diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 6e5397e..23dd30d 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -134,6 +134,11 @@ Each subdirectory of `FLOWMESH_PLUGIN_DIR` is importable as a top-level module. The mount is read-only, so the plugin code is treated as static deployment artifact. +For writable persistence, `FLOWMESH_PLUGIN_DATA_DIR` (default +`./plugin-data`) is mounted read-write at `/app/plugin-data`. A path +value is a host bind-mount (auto-created on `stack up`); a bare name +is an external Docker volume of that name. + This handles plugin **code** without rebuilding the server image. When that isn't enough, build a thin overlay on top of the prebuilt image. Two patterns, pick by need: