Skip to content

Commit 0bef744

Browse files
committed
feat(image): prepare WanGP models with progress
1 parent 2c03150 commit 0bef744

10 files changed

Lines changed: 586 additions & 5 deletions

File tree

.github/workflows/build-combined.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- main
88
paths:
99
- Dockerfile.combined
10+
- combined/**
1011
- scripts/combined-entrypoint.sh
1112
- scripts/combined-healthcheck.py
1213
- docs/combined-image-licenses.md

AGENTS.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@ The Python node package stays an MCP-only client: modules under `src`, tests,
99
and development scripts must not import WanGP or `mmgp`. All MCP calls still go
1010
through `_client.py`.
1111

12-
`Dockerfile.combined` is the sole exception to the no-bundling rule. It may
13-
redistribute a revision-pinned WanGP and `mmgp` in a separate Python environment
12+
`Dockerfile.combined` and modules under `combined/` are the sole exceptions to
13+
the no-bundling rule. They may redistribute and directly call a revision-pinned
14+
WanGP and `mmgp` in a separate Python environment
1415
for free, non-monetized use under the WanGP Community License 2.0 and the mmgp
1516
non-commercial license. The combined image must:
1617

1718
- include the complete WanGP license, mmgp license and attribution, and WanGP's
1819
bundled third-party notices;
1920
- identify the exact upstream revisions and state whether they were modified;
2021
- identify itself prominently as non-commercial and not endorsed by WanGP;
21-
- keep WanGP isolated from the AGPL NodeTool environment and connect over MCP;
22+
- keep WanGP isolated from the AGPL NodeTool environment. The public node
23+
package connects over MCP; combined-image adapters may call WanGP directly
24+
from the isolated WanGP interpreter;
2225
- never be sold, white-labelled, embedded in a paid product, or exposed as a
2326
paid, sponsored, ad-supported, SaaS, hosted, managed, API, OEM, marketplace,
2427
or otherwise monetized service without the necessary written licenses; and

Dockerfile.combined

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
ARG CORE_IMAGE=ghcr.io/nodetool-ai/nodetool:latest
55
FROM ${CORE_IMAGE}
66

7+
ARG NODETOOL_CORE_REPOSITORY=https://github.com/nodetool-ai/nodetool-core.git
8+
ARG NODETOOL_CORE_COMMIT=a85277a888ed48b884d447492cc75ba11ab731e4
79
ARG WANGP_REPOSITORY=https://github.com/deepbeepmeep/Wan2GP.git
810
ARG WANGP_COMMIT=057f9ecab9ad57dfbec9768b2daf7a4426ce986c
911
ARG MMGP_REPOSITORY=https://github.com/deepbeepmeep/mmgp.git
@@ -13,6 +15,7 @@ LABEL org.opencontainers.image.title="NodeTool WanGP non-commercial GPU worker"
1315
org.opencontainers.image.description="Authenticated Python execution worker and private WanGP MCP; free non-monetized evaluation only" \
1416
org.opencontainers.image.licenses="AGPL-3.0-or-later AND LicenseRef-WanGP-Community-2.0 AND LicenseRef-mmGP-NonCommercial" \
1517
org.opencontainers.image.source="https://github.com/nodetool-ai/nodetool-wan2gp" \
18+
io.nodetool.core.commit="a85277a888ed48b884d447492cc75ba11ab731e4" \
1619
io.nodetool.wangp.commit="057f9ecab9ad57dfbec9768b2daf7a4426ce986c" \
1720
io.nodetool.mmgp.commit="589ba050d320c4df879f48d11b23d6a88e6c68c8" \
1821
io.nodetool.commercial-use="prohibited-without-separate-written-license"
@@ -25,6 +28,11 @@ RUN apt-get update \
2528
libsndfile1 ninja-build python3-dev \
2629
&& rm -rf /var/lib/apt/lists/*
2730

31+
# Pin the worker protocol implementation independently of the moving base
32+
# image. Dependencies already come from CORE_IMAGE.
33+
RUN /opt/venv/bin/python -m pip install --no-deps \
34+
"git+${NODETOOL_CORE_REPOSITORY}@${NODETOOL_CORE_COMMIT}"
35+
2836
# WanGP deliberately lives in a separate environment. Its pinned MCP and
2937
# Pydantic versions conflict with the NodeTool environment.
3038
RUN /opt/venv/bin/python -m venv --copies /opt/wan2gp-venv \
@@ -68,13 +76,20 @@ RUN /opt/venv/bin/python -m pip install \
6876

6977
COPY scripts/combined-entrypoint.sh /usr/local/bin/nodetool-wangp-entrypoint
7078
COPY scripts/combined-healthcheck.py /usr/local/bin/nodetool-wangp-healthcheck
79+
COPY combined /opt/nodetool-wan2gp-combined
7180
RUN chmod 0755 /usr/local/bin/nodetool-wangp-entrypoint \
7281
/usr/local/bin/nodetool-wangp-healthcheck \
73-
&& mkdir -p /workspace/wan2gp/config /workspace/wan2gp/outputs /workspace/cache
82+
/opt/nodetool-wan2gp-combined/prepare_model.py \
83+
&& mkdir -p /workspace/wan2gp/config /workspace/wan2gp/models \
84+
/workspace/wan2gp/outputs /workspace/cache \
85+
&& ln -s /workspace/wan2gp/models /opt/Wan2GP/ckpts
7486

7587
ENV WAN2GP_MCP_URL=http://127.0.0.1:7866/mcp \
7688
WANGP_CONFIG_DIR=/workspace/wan2gp/config \
89+
WANGP_MODEL_DIR=/workspace/wan2gp/models \
7790
WANGP_OUTPUT_DIR=/workspace/wan2gp/outputs \
91+
WANGP_ROOT=/opt/Wan2GP \
92+
NODETOOL_MODEL_PREPARE_COMMAND_WANGP="/opt/wan2gp-venv/bin/python /opt/nodetool-wan2gp-combined/prepare_model.py" \
7893
HF_HOME=/workspace/cache/huggingface \
7994
PYTHONUNBUFFERED=1 \
8095
SDL_AUDIODRIVER=dummy \

combined/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Combined-image-only WanGP integration helpers."""

combined/download_telemetry.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Filesystem and process-I/O telemetry for opaque model downloads."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
import time
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
from typing import Callable, Iterable, TypedDict
11+
12+
13+
@dataclass(frozen=True)
14+
class _FileState:
15+
allocated_bytes: int
16+
size_bytes: int
17+
modified_ns: int
18+
19+
20+
class TelemetrySample(TypedDict):
21+
downloaded_bytes: int
22+
total_bytes: int
23+
downloaded_files: int
24+
total_files: int
25+
current_files: list[str]
26+
bytes_per_second: float
27+
seconds_since_activity: float
28+
elapsed_seconds: float
29+
free_bytes: int
30+
stalled: bool
31+
32+
33+
def _allocated_bytes(stat: os.stat_result) -> int:
34+
blocks = getattr(stat, "st_blocks", None)
35+
if isinstance(blocks, int):
36+
return max(0, blocks * 512)
37+
return max(0, stat.st_size)
38+
39+
40+
def _read_process_write_bytes(pid: int) -> int | None:
41+
try:
42+
text = Path(f"/proc/{pid}/io").read_text(encoding="utf-8")
43+
except (OSError, UnicodeError):
44+
return None
45+
for line in text.splitlines():
46+
key, separator, value = line.partition(":")
47+
if separator and key.strip() == "write_bytes":
48+
try:
49+
return max(0, int(value.strip()))
50+
except ValueError:
51+
return None
52+
return None
53+
54+
55+
class DownloadTelemetry:
56+
"""Measure download activity without intercepting downloader functions."""
57+
58+
def __init__(
59+
self,
60+
roots: Iterable[str | os.PathLike[str]],
61+
*,
62+
pid: int,
63+
stall_seconds: float = 90.0,
64+
clock: Callable[[], float] = time.monotonic,
65+
) -> None:
66+
if stall_seconds <= 0:
67+
raise ValueError("stall_seconds must be positive")
68+
self.roots = tuple(Path(root).resolve() for root in roots)
69+
if not self.roots:
70+
raise ValueError("at least one telemetry root is required")
71+
self.pid = pid
72+
self.stall_seconds = stall_seconds
73+
self._clock = clock
74+
self._files = self._snapshot_files()
75+
self._baseline_files = dict(self._files)
76+
self._baseline_process_bytes = _read_process_write_bytes(pid)
77+
self._last_process_bytes = self._baseline_process_bytes
78+
self._started_at = self._clock()
79+
self._sampled_at = self._started_at
80+
self._last_activity_at = self._started_at
81+
self._downloaded_bytes = 0
82+
83+
def _snapshot_files(self) -> dict[Path, _FileState]:
84+
files: dict[Path, _FileState] = {}
85+
for root in self.roots:
86+
if not root.exists():
87+
continue
88+
stack = [root]
89+
while stack:
90+
directory = stack.pop()
91+
try:
92+
entries = list(os.scandir(directory))
93+
except OSError:
94+
continue
95+
for entry in entries:
96+
try:
97+
if entry.is_dir(follow_symlinks=False):
98+
stack.append(Path(entry.path))
99+
elif entry.is_file(follow_symlinks=False):
100+
stat = entry.stat(follow_symlinks=False)
101+
files[Path(entry.path)] = _FileState(
102+
allocated_bytes=_allocated_bytes(stat),
103+
size_bytes=max(0, stat.st_size),
104+
modified_ns=max(0, stat.st_mtime_ns),
105+
)
106+
except OSError:
107+
continue
108+
return files
109+
110+
def _display_path(self, path: Path) -> str:
111+
for root in self.roots:
112+
try:
113+
return str(path.relative_to(root))
114+
except ValueError:
115+
continue
116+
return path.name
117+
118+
def _free_bytes(self) -> int:
119+
values: list[int] = []
120+
for root in self.roots:
121+
probe = root
122+
while not probe.exists() and probe != probe.parent:
123+
probe = probe.parent
124+
try:
125+
values.append(shutil.disk_usage(probe).free)
126+
except OSError:
127+
continue
128+
return min(values) if values else 0
129+
130+
def sample(self) -> TelemetrySample:
131+
"""Return one monotonic activity sample in manager-compatible fields."""
132+
now = self._clock()
133+
files = self._snapshot_files()
134+
changed: list[Path] = []
135+
for path, state in files.items():
136+
previous = self._files.get(path)
137+
if previous is None or state != previous:
138+
changed.append(path)
139+
140+
filesystem_delta = sum(
141+
max(0, state.allocated_bytes - self._baseline_files.get(path, _FileState(0, 0, 0)).allocated_bytes)
142+
for path, state in files.items()
143+
)
144+
process_bytes = _read_process_write_bytes(self.pid)
145+
process_delta = 0
146+
if process_bytes is not None and self._baseline_process_bytes is not None:
147+
process_delta = max(0, process_bytes - self._baseline_process_bytes)
148+
149+
measured = max(filesystem_delta, process_delta)
150+
previous_downloaded = self._downloaded_bytes
151+
self._downloaded_bytes = max(self._downloaded_bytes, measured)
152+
process_moved = (
153+
process_bytes is not None
154+
and self._last_process_bytes is not None
155+
and process_bytes > self._last_process_bytes
156+
)
157+
if changed or process_moved or self._downloaded_bytes > previous_downloaded:
158+
self._last_activity_at = now
159+
160+
elapsed = max(0.0, now - self._sampled_at)
161+
bytes_per_second = (
162+
max(0, self._downloaded_bytes - previous_downloaded) / elapsed
163+
if elapsed > 0
164+
else 0.0
165+
)
166+
quiet = max(0.0, now - self._last_activity_at)
167+
active_files = sorted(self._display_path(path) for path in changed)[:20]
168+
self._files = files
169+
self._last_process_bytes = process_bytes
170+
self._sampled_at = now
171+
172+
return {
173+
"downloaded_bytes": self._downloaded_bytes,
174+
"total_bytes": 0,
175+
"downloaded_files": 0,
176+
"total_files": 0,
177+
"current_files": active_files,
178+
"bytes_per_second": round(bytes_per_second, 2),
179+
"seconds_since_activity": round(quiet, 2),
180+
"elapsed_seconds": round(max(0.0, now - self._started_at), 2),
181+
"free_bytes": self._free_bytes(),
182+
"stalled": quiet >= self.stall_seconds,
183+
}

0 commit comments

Comments
 (0)