Skip to content

feat(migrate): wandb → Pluto historical data migration - #131

Open
asaiacai wants to merge 53 commits into
mainfrom
feat/wandb-migrate
Open

feat(migrate): wandb → Pluto historical data migration#131
asaiacai wants to merge 53 commits into
mainfrom
feat/wandb-migrate

Conversation

@asaiacai

@asaiacai asaiacai commented Jul 7, 2026

Copy link
Copy Markdown

Summary

Two-phase migration tool for importing legacy wandb data into Pluto with full timestamp fidelity — built for the first customer coming off the wandb shim who wants their historical runs transferred.

  • pluto migrate wandb export — stages complete runs from the wandb cloud API on disk (parquet long-schema parts + run.json manifest + media/artifact files): full scan_history metrics, media, tables/histograms, events-stream system metrics, console logs, artifacts (size-cappable). Atomic per-run staging with sentinel-based resume.
  • pluto migrate wandb load — replays staged runs through the public client API with original wall-clock timestamps, wandb::{entity}/{project}/{run_id} external-id dedup, crash-healing resume (typed RunExistsError → resume + re-replay), batched metric enqueue, bounded backpressure, and --dry-run.
  • pluto migrate wandb all — both phases; loads whatever staged even if some exports failed.

Client groundwork

  • op.log(..., timestamp=) — epoch-seconds override threaded through the sync store (wire format already carried per-point time); works in sync, legacy, and perf-queue modes.
  • Op._log_console / Op._log_metrics_batch — batched backfill helpers.
  • settings.disable_system_metrics — keeps the migration host's hardware/health metrics (sys/*, sys/pluto.*, run systemMetadata) out of imported runs, including in the sync subprocess.
  • New migrate extra (pip install 'pluto-ml[migrate]'): wandb + pyarrow, lazily imported so the base CLI/package are unaffected.

Server dependency

Run createdAt/updatedAt backfill needs the companion server PR (Trainy-ai/server-private branch feat/run-createdat-backfill); metric/file/console point timestamps already round-trip against today's prod ingest. Until it deploys, imported runs show import-day creation dates only.

Test plan

  • 71 new/updated unit tests (TDD): timestamp threading, monitor suppression, parquet round-trip/rotation, exporter (fake wandb API fixtures), loader (mocked init), CLI wiring.
  • tests/test_migrate_staging_e2e.py: live round-trip against the dev channel, gated on PLUTO_STAGING_API_KEY.
  • Multi-angle code review with 10 confirmed findings — all fixed in the final commit.

🤖 Generated with Claude Code


Note

High Risk
Large new migration and sweep surfaces touch auth, run lifecycle, sync store schema (annotations column), and direct data-ingest paths; incorrect resume or timestamp handling could duplicate media or skew run duration until server compat fields deploy.

Overview
This PR adds a two-phase wandb → Pluto migration (pluto migrate wandb export|load|all): cloud export to on-disk parquet parts + manifests, then replay via the public client with original wall-clock times, external-id dedup, resume ledgers, parallel project workers, and load backpressure. The migrate extra (wandb + pyarrow) is lazy-loaded so the base CLI still works without it.

Backfill plumbing extends normal runs only where needed: log(..., timestamp=) (epoch seconds), batched _log_metrics_batch / _log_console, settings.disable_system_metrics (including the sync subprocess), compat createdAt/updatedAt on run create and statusUpdated on finish, and typed RunExistsError for migration resume. Auth no longer overwrites an explicitly provided token with _key after a failed login POST.

Media gains Image boxes/masks/annotations (mask PNGs as fileType mask, annotations on file uploads). String metrics route to string-series ingest (live log + migration loader). pluto.sweep / pluto.agent (grid/random/bayes via optuna) tag runs sweep:<id> and merge sampled config in init(); migrated wandb sweeps are preserved similarly.

Docs/API metadata updated for timestamp, Image kwargs, and sweep symbols. Run creation dates on import still depend on the companion server backfill PR; point timestamps already round-trip on ingest.

Reviewed by Cursor Bugbot for commit 0f61ca8. Configure here.

Summary by CodeRabbit

  • New Features
    • Added pluto migrate wandb CLI with export, load, and all workflows, resumable staging/loading, project scoping, and configurable parallel workers.
    • Added optional timestamp support for log(...) to improve historical replay fidelity.
    • Added disable_system_metrics to suppress host hardware/system metrics during backfills and migrations.
  • Bug Fixes
    • Prevented explicitly provided auth tokens from being overwritten after transient connectivity failures.
    • Backfilled/migrated compatibility payload now preserves historical statusUpdated timing.
  • Documentation
    • Updated log(...) documentation to explain timestamp semantics and invalid-value handling.

Ubuntu and others added 8 commits July 7, 2026 03:32
… disable_system_metrics

Groundwork for pluto.migrate (wandb importer): explicit historical
timestamps thread through to the sync layer (wire format already
carried them), console lines can be replayed with original times, and
the importing host's system metrics can be suppressed so they don't
pollute migrated runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-schema PartWriter with size-based part rotation, part readers,
atomic-JSON state helpers, export sentinels, and the load-phase
LoadedCache. wandb+pyarrow become the optional 'migrate' extra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-fidelity export via wandb.Api: scan_history metrics with original
step/timestamp, media/histogram rows, events-stream system metrics
(renamed system.* -> sys/*), console lines from output.log (parsing
per-line timestamps when present), artifacts with a size cap, and
atomic per-run staging with sentinel-based resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-step metric replay with original timestamps, run createdAt via
settings.compat, media/table/histogram conversion, console + artifact
replay, sync-queue backpressure, finish-code mapping, external-id
dedup, loaded-cache resume, and dry-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thin argparse layer over WandbExporter/PlutoLoader; heavy deps import
inside handlers so the base CLI works without the migrate extra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loads a hand-staged export into the dev/staging environment via
PlutoLoader and verifies through pluto.query that historical metric
timestamps, tags, and (once the server fix deploys) run createdAt
round-trip. Gated on PLUTO_STAGING_API_KEY; URLs default to the
pluto-*-dev.trainy.ai channel and are overridable via env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- perf-mode log() now carries timestamp through the queue (was silently
  dropped when sync is disabled)
- collision on externalId resumes and re-replays instead of permanently
  marking a half-loaded run as done (typed RunExistsError in op.py)
- metric replay batches groups through one SQLite transaction
  (Op._log_metrics_batch / SyncProcessManager.enqueue_metrics_batch)
- disable_system_metrics now reaches the sync subprocess (sys/pluto.*
  health metrics no longer stamp migrated runs with current time) and
  suppresses host systemMetadata on run creation
- backpressure wait is bounded (stall_timeout) with guarded polling
- staged system metrics keep source-native names; loader owns the sys/
  translation; console lines are no longer rewritten when they carry
  their own timestamps
- 'all' loads staged runs even when some exports failed; 'all --dry-run'
  is rejected instead of silently exporting; --artifact-max-size-mb 0
  means a zero cap, not unlimited

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust two-phase migration pipeline (pluto.migrate) to export historical experiment data from Weights & Biases to local parquet files and load them into Pluto while preserving original timestamps. The changes include new CLI commands, exporter and loader modules, resume bookkeeping, and support for historical timestamps and batch logging in the core Op class. The code review identified critical path traversal vulnerabilities when handling externally-sourced identifiers (such as run IDs, file names, and artifact names) from the wandb API and staged files. Additionally, several bugs and robustness issues were highlighted, including a missing defaultdict import in pluto/op.py, potential crashes from malformed JSON or non-string inputs, uncleaned temporary directories on export failure, and platform-dependent file opening without explicit UTF-8 encoding.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pluto/op.py
Comment on lines +701 to +702
new_metric_names: List[str] = []
new_file_meta: Dict[str, List[str]] = defaultdict(list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The defaultdict class is used here but it is not imported in this file, which will raise a NameError when _log_metrics_batch is called. Import defaultdict from collections locally or at the top of the file to prevent this crash.

Suggested change
new_metric_names: List[str] = []
new_file_meta: Dict[str, List[str]] = defaultdict(list)
from collections import defaultdict
new_metric_names: List[str] = []
new_file_meta: Dict[str, List[str]] = defaultdict(list)

if self.before_ms is not None and created_ms > self.before_ms:
continue

run_dir = runs_root / run.id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The run.id is an externally-sourced identifier from the wandb API. Using it directly to construct run_dir can lead to path traversal vulnerabilities if a malicious run ID contains .. or path separators. Sanitize it using Path(run.id).name and check for unsafe values like . or .. before constructing the path.

Suggested change
run_dir = runs_root / run.id
safe_run_id = Path(run.id).name
if not safe_run_id or safe_run_id in ('.', '..'):
logger.warning(f'{tag}: unsafe run ID {run.id!r}, skipping')
continue
run_dir = runs_root / safe_run_id
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

Comment thread pluto/migrate/wandb_export.py Outdated
Comment on lines +318 to +322
for f in run.files():
if not self.include_files and f.name != 'output.log':
continue
try:
f.download(root=str(files_dir), exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The f.name is an externally-sourced file path from the wandb API. To prevent path traversal vulnerabilities, sanitize it using Path(f.name).name and check for unsafe values like . or .. before downloading.

        for f in run.files():
            if not self.include_files and f.name != 'output.log':
                continue
            safe_name = Path(f.name).name
            if not safe_name or safe_name in ('.', '..') or safe_name != f.name:
                logger.warning(f'{tag}: unsafe file name {f.name!r}, skipping')
                continue
            try:
                f.download(root=str(files_dir), exist_ok=True)
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

f'({size} bytes > cap {self.artifact_max_bytes})'
)
continue
dest = tmp_dir / 'artifacts' / artifact.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The artifact.name is an externally-sourced identifier from the wandb API. To prevent path traversal vulnerabilities, sanitize it using Path(artifact.name).name and check for unsafe values like . or .. before constructing the download destination path.

            safe_name = Path(artifact.name).name
            if not safe_name or safe_name in ('.', '..'):
                logger.warning(f'{tag}: unsafe artifact name {artifact.name!r}, skipping')
                continue
            dest = tmp_dir / 'artifacts' / safe_name
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

Comment thread pluto/migrate/loader.py Outdated
Comment on lines +262 to +263
path = run_dir / (row['file_value'] or '')
if not row['file_value'] or not path.exists():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The row['file_value'] is read from the staged parquet files, which could be manipulated or contain unsafe paths. To prevent path traversal vulnerabilities, sanitize it using Path(file_value).name and check for unsafe values like . or .. before checking for its existence or loading it.

Suggested change
path = run_dir / (row['file_value'] or '')
if not row['file_value'] or not path.exists():
file_value = row['file_value'] or ''
safe_file_value = Path(file_value).name
if not safe_file_value or safe_file_value in ('.', '..') or safe_file_value != file_value:
logger.warning(f'{tag}: unsafe file path {file_value!r}, skipping')
return
path = run_dir / safe_file_value
if not path.exists():
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

Comment on lines +50 to +53
def parse_iso_ms(value: Optional[str]) -> Optional[int]:
"""Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive)."""
if not value:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parse_iso_ms function assumes value is always a string. If the wandb API returns a non-string type (such as an integer or a datetime object), calling .replace will raise an AttributeError and crash the export. Add a defensive check to ensure value is a string.

Suggested change
def parse_iso_ms(value: Optional[str]) -> Optional[int]:
"""Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive)."""
if not value:
return None
def parse_iso_ms(value: Optional[str]) -> Optional[int]:
"""Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive)."""
if not isinstance(value, str):
return None
try:

Comment on lines +152 to +156
def _export_run(self, run: Any, run_dir: Path) -> None:
tmp_dir = run_dir.with_name(run_dir.name + '.tmp')
if tmp_dir.exists():
shutil.rmtree(tmp_dir) # leftovers from an interrupted export
tmp_dir.mkdir(parents=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If an exception occurs during _export_run (e.g., during history scanning or file downloading), the partial .tmp directory is left on disk. Wrap the export logic in a try-except block to clean up tmp_dir on failure so orphaned temporary directories do not accumulate.

    def _export_run(self, run: Any, run_dir: Path) -> None:
        tmp_dir = run_dir.with_name(run_dir.name + '.tmp')
        if tmp_dir.exists():
            shutil.rmtree(tmp_dir)  # leftovers from an interrupted export
        tmp_dir.mkdir(parents=True)

        try:
            created_ms = parse_iso_ms(getattr(run, 'created_at', None))
            with PartWriter(tmp_dir) as writer:
                self._write_run_json(run, tmp_dir, created_ms)
                self._export_history(run, writer)
                if self.include_system:
                    self._export_system_metrics(run, writer)
                files_dir = tmp_dir / 'files'
                if self.include_files or self.include_console:
                    self._download_files(run, files_dir)
                if self.include_console:
                    self._export_console(run, writer, files_dir, created_ms)
                if self.include_artifacts:
                    self._export_artifacts(run, writer, tmp_dir)

            mark_run_exported(tmp_dir, {'rows': writer.rows_written})
            if run_dir.exists():
                shutil.rmtree(run_dir)
            os.rename(tmp_dir, run_dir)
        except Exception:
            if tmp_dir.exists():
                shutil.rmtree(tmp_dir)
            raise

return
base = self._row_base(run)
fallback_ms = created_ms or 0
with open(output_log, errors='replace') as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.

Suggested change
with open(output_log, errors='replace') as f:
with open(output_log, encoding='utf-8', errors='replace') as f:

Comment thread pluto/migrate/state.py
def write_json_atomic(path: Union[str, Path], obj: Any) -> None:
path = Path(path)
tmp = path.with_name(path.name + '.tmp')
with open(tmp, 'w') as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.

Suggested change
with open(tmp, 'w') as f:
with open(tmp, 'w', encoding='utf-8') as f:

Comment thread pluto/migrate/state.py


def read_json(path: Union[str, Path]) -> Any:
with open(path) as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.

Suggested change
with open(path) as f:
with open(path, encoding='utf-8') as f:

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ubuntu and others added 11 commits July 29, 2026 08:31
Each concurrent project holds its run history + upload queue in memory,
so `all --workers N` across many projects can exhaust a host's RAM —
observed a swapless 31 GB box OOM under the old default of 8 (which
resolves to min(8, projects) concurrent pipelines). Default to 4 and
document the ~2-4 GB/worker cost in --help so users on small machines
lower it and only raise it when the RAM is there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed run

Re-migrating a run already on the server corrupted it: the loader's
existence probe calls pluto.init(run_id=external_id), and the server's
create-with-existing-externalId path (DDP-style resume) flips the run
back to RUNNING and stamps statusUpdated=now() — intended, and not ours
to change. The loader then caught RunExistsError and skipped, walking
away and leaving the run stuck RUNNING with a now() finish time, so its
Duration read time-since-import (years).

On the skip path, re-attach (resume=True) and finish() — no replay — to
restore the run's terminal status + historical statusUpdated. Verified
end-to-end on staging: a run corrupted to FAILED/now() came back
COMPLETED with its real historical finish time. This makes re-migration
idempotent and repairs already-corrupted runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A crash/OOM mid-export truncates wandb's run-history parquet to 0 bytes
in ~/.cache/wandb; wandb then reuses the empty file on every later read
and the run fails to export forever with "Parquet file too small. Size
is 0" — silently losing runs on a re-run (observed: 4 runs vanished
after an OOM crash mid-migration).

- Purge 0-byte parquets from wandb's cache at the start of every export
  (a valid parquet is never empty, so removal is always safe; wandb
  re-downloads real data). This means a crash can never poison a later
  export.
- Retry each run once on export failure, purging empties first, to
  recover from corruption that appears mid-migration or a transient
  network blip instead of dropping the run.
- Log the purge at WARNING (not INFO, which the CLI suppresses) and name
  the cleared files, so the recovery is visible.

A/B verified on real runs: with a planted 0-byte parquet, the old
exporter fails every time (run missing); the new one clears it and
exports cleanly. Adds retry + purge tests. 91 migrate tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two data types that previously fell through the wandb -> Pluto migration
are now carried over.

String-series (categorical/status history, e.g. phase=warmup->train->done):
- exporter stages non-numeric string history as string_series rows instead
  of dropping them, with a >200-char per-point length guard
- loader buffers per key, applies a >50-distinct cardinality guard (free-form
  text is not a state timeline), then POSTs NDJSON to /ingest/data
  (dataType="string-series", raw data) and registers the log name as DATA via
  /api/runs/logName/add. Non-fatal on failure.
- schema: add 'string_series' to ATTRIBUTE_TYPES

Custom charts (wandb.plot.*):
- the panel spec is absent from the run API but present in the raw config.yaml
  under _wandb.value.visualize; the exporter parses it into custom_charts.json
  (preset, title, backing tableKey, field mappings, specLang) with a
  custom-chart / custom-chart-unsupported coverage split
- loader forwards the panels into run.config.wandb.custom_charts so the Pluto
  side can rebuild each chart from preset + the already-migrated backing table

Verified end-to-end against a local Pluto stack (string-series lands in
mlop_data with logType DATA; custom_charts land in run config with backing
tables as TABLE). Adds exporter/loader/schema unit tests; migrate suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… ids

Per the server-private render side:
- forward the whole panel_config.stringSettings dict as `strings` (not just
  `title`) so the renderer can substitute axis titles (x-axis-title/
  y-axis-title); title is kept as-is for backward compat.
- add wandb/area-under-curve/v0 (shared by pr_curve + roc_curve),
  wandb/confusion_matrix/v1, and wandb/lineseries/v0 to the preset map so
  those panels count as migrated custom-charts instead of
  custom-chart-unsupported (the renderer dispatches on panelDefId, so the
  payload already worked — this only fixes migration stats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ubuntu and others added 11 commits August 1, 2026 00:54
…ity guard)

pluto.log({"phase": "warmup"}) now works natively, mirroring the wandb
migration: a bare string value is routed to the string-series data path
(mlop_data, dataType="string-series", raw value, logName registered as DATA)
instead of being silently dropped. Numeric/media/data paths are unchanged.

- op.py: route str values in _process_log_item_sync; register string keys under
  the DATA log type; new _enqueue_string_series_sync helper.
- sync/process.py: send string-series `data` raw (not JSON-wrapped), matching
  the migration wire and the reader; widen enqueue_data's data_dict type.

No cardinality/distinct-value guard: every string value is kept (no data loss),
regardless of how many distinct values the series has. The only value not sent
is a single point over 200 chars (a stray blob, not a state label), warned once
per key. The migration loader's cardinality guard is likewise removed.

Verified end-to-end against a local stack: phase -> string-series timeline,
an all-distinct 12-point series keeps all 12 points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a native sweep API mirroring wandb's, single-machine (no launcher):

- pluto.sweep(config) -> id: validate a wandb-shaped search space
  ({method, metric, parameters}); grid + random supported (bayes rejected with
  a clear message, planned via optuna). Stored in-process + on disk.
- pluto.agent(sweep_id, fn, count): client-side "brain" enumerates (grid) or
  samples (random) the space and runs fn once per combination. The sampled
  hyperparameters are injected into each run's config and the run is tagged
  sweep:<id> (init.py hook), so runs group under their sweep — exactly the data
  model a sweep dashboard needs. Auto-finishes runs the fn leaves open.

Migration: the exporter now captures a run's wandb sweep (id/name/search-space
config) into the manifest instead of flagging it dropped; the loader tags the
run sweep:<id> and stores the sweep under config.wandb.sweep. Native and
migrated sweeps converge on the same tag+config model.

Verified end-to-end against a local stack: a 2x2 grid produces 4 tagged runs
with their combos; a real wandb sweep run migrates tagged with its search space.
Adds tests/test_sweep.py; migrate tests updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resume (grid + random), no backend endpoint needed — the SDK lists a sweep's
COMPLETED runs via pluto.query.list_runs(tags=["sweep:<id>"]):
- grid: skip combinations whose run already completed.
- random: run count - (already done).
Best-effort: if the query fails (offline / project not created), the agent just
runs everything, so a fresh sweep is never blocked.

Bayesian search (method="bayes") via optuna (optional dep, pluto[sweep]):
- agent asks optuna for each next combination, runs it, and tells optuna the
  objective (the run's final metric value), learning as it goes.
- direction from metric.goal (minimize/maximize); requires metric + count.
- resume seeds the study from completed runs' (params -> objective) so a
  restarted bayes search keeps learning; also caps at the total count.

Supporting change: Op caches the latest numeric value per key
(op._latest_metrics), and pluto.init hands the sweep run's Op back to the agent,
so the objective is read directly (pluto.ops is unreliable here — finish()
mutates it and object ids get reused).

Verified end-to-end on a local stack: grid resume runs only the remaining
combinations; a bayes search over (x-0.7)^2 converges near x=0.7. Adds bayes +
resume tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A native pluto.sweep() previously put only the sampled combo + the sweep:<id>
tag on each run, so the server never saw the sweep's method / objective / search
space and had to infer them. The agent now also stamps the declared spec onto
each run's config as `config.sweep` = {id, method, metric, parameters} — the
same shape migrated sweeps carry in `config.wandb.sweep`. The sweep dashboard
can now read the real declaration (correct optimize goal, the actual search
space, grid/random/bayes) instead of guessing, without any backend endpoint.

Implementation: agent() sets a module-level `_active_declared` for the run and
init() stamps it alongside the combo; cleared in a finally (covers the bayes
early-return). Adds a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

Adds an `annotations` field to the file-upload path (mirrors `caption`) that
carries a wandb-shape {boxes, masks} JSON string, forwarded verbatim to the
server (mlop_files.annotations) as the render side expects.

- pluto.Image now takes `boxes` (native, {layer: {box_data, class_labels}}) and
  `annotations` (a ready JSON blob, used by migration). Boxes default to
  domain="pixel" so coords aren't misread as 0-1 fractions.
- Plumbing: File._annotations -> op._enqueue_file_sync -> SyncManager.enqueue_file
  -> store (file_uploads.annotations column, v3 additive migration) ->
  upload_files_batch (only sent when set, like caption).
- Migration: the exporter stages the wandb box/mask refs in a new parquet
  `annotation_value` column (was: self._skipped('image-annotations')); the loader
  resolves the .boxes2D.json sidecar into the image's annotations. Coverage now
  reports `image-boxes` migrated.

Verified end-to-end into the sync DB for both native (boxes with domain=pixel)
and migration (real run's boxes resolved to {box_data, class_labels}); the field
name matches the ingest's serde `annotations` exactly.

Masks (a separate PNG to re-upload + hide from the media grid) are still
deferred — flagged `image-masks`. Adds exporter + loader tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes image annotations. Each mask layer becomes a PNG uploaded in the
image's log group with fileType "mask" (the convention the render side hides
from the media grid and resolves by fileName), referenced from the image's
annotations as {fileName, class_labels?}.

- pluto.Image now takes `masks`: native {layer: {mask_data: <HxW class-id
  array>, class_labels}} is encoded to a PNG with the class id in the red
  channel (what the renderer reads); migration {layer: {path}} forwards wandb's
  own mask.png. Mask PNGs to upload alongside are collected in
  `_annotation_files`.
- op._enqueue_file_sync uploads those sub-files in the same log group and honors
  File._upload_file_type; the sync payload sends fileType "mask" for them
  (reuses the file_type field — no new column).
- Migration: masks now migrate (was flagged image-masks); the loader resolves
  the staged .mask.png ref to a path and hands it to pluto.Image.

Verified end-to-end into the sync DB: native (numpy → red-channel PNG) and
migration (real run) both produce an image row with boxes+masks annotations and
a sibling mask row with fileType=mask whose fileName matches the reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sweep commits added optuna to pyproject.toml (optional dep, dev dep,
and the [sweep] extra) but never regenerated poetry.lock, leaving a stale
content-hash. CI's `poetry install` refuses an out-of-sync lock, so every
job failed at the "Install dependencies" step (format, mypy, tests,
api-docs, contract-test — all 9).

Re-locked with poetry 2.1.1 (matches CI). Adds only optuna + its
transitive deps (alembic, colorlog, greenlet, mako, sqlalchemy); no other
package versions change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wandb keeps two copies of a media table; the exporter stages the lossy
run-files copy, so image cells load as the literal text "Image" rather
than pictures. Pin that current behavior: the table migrates (not
dropped, no crash) and the cell images arrive as a separate, unlinked
artifact. This test should flip to assert real image cells when we wire
cell refs to uploaded images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…annotations

Two regressions from the sweep and image-annotation commits, surfaced once
CI's install step started passing:

- _log_via_sync() unconditionally did self._latest_metrics.update(metrics),
  but call sites that build a bare Op via Op.__new__ (log-resilience unit
  tests) skip __init__ where it's initialized -> AttributeError. Logging is
  best-effort and must never crash, so lazy-init the cache at the use site
  (the sweep reader already uses getattr defensively).

- pluto.Image gained boxes/masks/annotations and the public API gained
  sweep/agent, leaving docs-api/media.mdx and meta.json stale (api-docs
  --check failed). Regenerated with griffe 2.0.2 (matches the lock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A wandb.Table with media columns migrates degraded: we stage wandb's
lossy run-files table copy (media cells are text placeholders like
"Image"), and the cell media arrives only as the sibling run_table
artifact, unlinked from the table. Previously this happened silently.

The exporter now detects media columns by reading the downloaded table
artifact's column_types for media *-file wb_types, and emits a
table-media-cell coverage flag + warning (trips --strict). The table and
its scalar columns still migrate; only the in-cell media is flagged as
lost, mirroring the artifact-versioning partial-migration pattern.

Verified against a real seeded media table (NOT migrated: 1
table-media-cell) and with unit tests for media vs plain tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lags

Add export-side coverage for the two skip paths not exercised by the
seeded fixtures: the generic unsupported(<type>) branch (bokeh/joined-
table/... media types the exporter can't stage) and string-series-too-long
(a per-step string over the 200-char cap). FakeRun gains a history_rows
override so a test can drive scan_history directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

ryanhayame commented Aug 5, 2026

Copy link
Copy Markdown

wandb → Pluto migration — summary

pluto migrate wandb brings historical wandb runs into Pluto in two phases: export (wandb cloud → on-disk staging) and load (staging → Pluto via the public client). all runs both. Each wandb project maps to a same-named Pluto project.

The render half is server-private#559 — it draws what this PR imports (custom charts, media viewers, string metrics, sweeps, image overlays). Everything below is verified end-to-end against real migrated data. Native SDK users (pluto.log, pluto.sweep, pluto.Image) get the same features on the same code path.

Screenshots: every row below is the same seeded data captured in wandb (cloud) and Pluto (render branch #559 on localhost) — apples-to-apples. Visual features (boxes/masks, Plotly/3D, sweeps) are shown across multiple areas (Charts + Files/list).

Commands

Goal Command
Migrate one project, end-to-end pluto migrate wandb all --entity E --project P
Several specific projects (repeat --project) pluto migrate wandb all --entity E --project P --project B
A whole account (every project) pluto migrate wandb all --entity E
Everything except some projects pluto migrate wandb all --entity E --exclude foo
Only specific runs (optional subset) pluto migrate wandb all --entity E --project P --run-id abc123 --run-id def456
Two-phase — stage to disk… pluto migrate wandb export --entity E --output ./stage
…then load from disk pluto migrate wandb load --input ./stage
Go faster (more parallelism) … --workers 16
Free staged disk as it loads … --cleanup
Preview a load, write nothing pluto migrate wandb load --input ./stage --dry-run
Fail loudly if anything can't migrate … --strict

Knobs

Flag Phase Default Meaning
--project all all repeatable; scope to specific project(s)
--exclude all none skip project(s) during an all-projects sweep
--workers all 4 file-download threads per run and projects migrated concurrently. Each project pins its history + upload queue in RAM (~2-4 GB), so raise only with headroom.
--cleanup load / all off delete each run's staged files once confirmed loaded
--dest-project load / all source name rename the destination (single project only)
--strict export off non-zero exit if any data couldn't be migrated
--dry-run load off report what would load; write nothing
--run-id export / load / all all runs repeatable; migrate only these run id(s). Omit (the default) to migrate the whole project — you never need to list runs
--after / --before export date filter on which runs to export

What migrates

Each row is the same seeded data in wandb and Pluto — screenshots, not links. Visual features are shown across multiple areas. Nothing crashes on any migrated run.

What migrates wandb Pluto
Scalar metrics — int/float series, original step + timestamp; NaN/±Inf preserved
Histograms — real bin edges rebuilt from packedBins; ridgeline/heatmap section
Images — single and list-logged (order preserved via sampleIndex)
Video — incl. list-logged, step slider
Audio — incl. list-logged
Tables — grid in Charts + raw JSON in /files; bool/unicode columns Charts

/files
Run metadata · env · timestamps — name/notes/tags, state, config, git/OS/Python, real Duration
Console logs — stdout/stderr; unicode, ansi, stderr preserved
Artifact fileswandb.Artifact files → Pluto Artifacts
HTML (wandb.Html) — sandboxed iframe preview in /files
Plotly · matplotlib · 3D point clouds — arrive as artifact JSON, render as interactive viewers. mpl is stored as a Plotly figure. Plotly

3D cloud
Sweepssweep:<id> tag + search space → a Sweeps tab per project (parallel coords, parameter importance, best run). Same wandb sweep cvxvtpim on both sides. Native pluto.sweep() identical. Sweeps tab

Detail
String / status history — non-numeric per-step series (phase: warmup→train→…) → step chart (categories on Y). wandb can't chart it; native pluto.log too.
Custom charts (wandb.plot.*) — presets render as Vega panels (wandb's own spec, fetched + frozen); backing table migrates too.
Image boxes & maskswandb.Image(boxes=, masks=) overlays migrate and both render. Masks need a colour key (class_labels) that wandb hides in the run config, not the mask file — the exporter now recovers it, so migrated masks paint the same as native pluto.Image(masks=). Dashboard

/files

What does NOT fully migrate

Each of these warns you during migration (and stops it in --strict mode) — nothing is dropped silently. They all need a fix on the export side plus a re-run to work. (There used to be a second list — things that migrated fine and only needed the frontend to draw them — but #559 cleared it out.)

What can't fully migrate What's missing Warning it prints Effort
Joined / partitioned tables the whole table (plain tables are fine) unsupported(<type>) medium-high
Artifact version history & aliases the version graph (the files themselves do migrate) artifact-versioning high
"Which run used which artifact" that link between runs and artifacts artifact-input-lineage high
Bokeh / molecule files the file itself never comes over unsupported(<type>) medium

Masks: one deployment caveat (CORS). Masks now render (see above — the exporter recovers class_labels from the run config). But colouring a mask happens in your browser, which has to read the mask file's pixels — and browsers only allow that if the storage server sends "CORS" headers. MinIO (the local default) sends them; AWS S3 doesn't unless you set it up, and our Terraform doesn't — so on a self-hosted AWS deployment, masks won't display until that's added. Photos and boxes are fine either way.

Tables with images inside their cells migrate, but the images come loose. wandb keeps two copies of such a table: a full one (with real image links) and a stripped one where each image cell is just the word "Image". We currently grab the stripped copy, so the table shows "Image" as text where the pictures should be. The pictures themselves do migrate — but as a separate, unlinked file collection in the Files tab. It's no longer silent: migration now spots these tables and warns you (table-media-cell, stops --strict). Nothing is lost from disk — what's missing is the link from cell to picture. Fix: grab the full table copy and wire each cell to its uploaded image (us) + show images inside table cells (frontend).

These aren't real gaps: artifact-over-size-cap and media-file(--no-files) only happen when you ask for them (you set a size limit, or pass --no-files); file-download-failed is a temporary network error that's already retried automatically. Deliberately not read (out of scope): wandb Reports & saved views, the Model Registry, and internal _-prefixed keys. System metrics do migrate and we verified it — a stats-enabled test run captured CPU/memory samples and they landed in the database.

Still to do: artifact version-history graph (frontend); a custom-chart dashboard widget (frontend — they already render on the run and all-runs pages); and hand-drawn custom charts (custom-chart-unsupported — wandb won't hand over the drawing recipe, so Pluto shows a link to the underlying data table, which does migrate).

Sweeps — how Pluto differs from wandb

In Pluto a sweep is just "all the runs tagged with that sweep's id" — no new database tables. Because of that, Pluto works out a sweep's state (running / finished / stopped early) from its runs, instead of storing one like wandb does. A few side effects: a crashed run keeps a sweep looking "running" until a background cleanup notices (~30 min); "stopped early" only applies to grid sweeps; and the run count is "how many were attempted" (a 6-run grid where 4 crashed still says "6/6", with the failures shown next to it). Parameter importance (which knob mattered most) uses the same math as wandb — the ranking matches, though the exact bar lengths won't, because wandb keeps its random seed secret. Not built: starting / pausing / stopping / resuming a sweep from the UI — all of which would need a live sweep controller running.

Benchmarks

We built one big test project of 740 runs that deliberately includes every data type and every awkward edge caseNaN / ±Inf / nulls, runs that failed and crashed, every kind of sweep (grid / random / bayes, finished and failed), tables mixing numbers/text/true-false/blanks, tables with images in cells, boxes and masks, artifacts of every flavour, molecule/bokeh/joined-table, image galleries, system metrics, and more. We seeded it to wandb, migrated it into Pluto on the local dev stack (with the #559 frontend), and checked it three ways: how long it took, whether any data went missing (counted directly in the database), and whether it renders correctly in the app.

Coverage — we hit every case, and checked each one. There are two kinds of data:

  • Fully brought over (11 kinds): plain metrics, media (images / video / audio), image boxes & masks, histograms, system metrics, console logs, string metrics, sweeps, custom charts, and artifact files. All 11 showed up correctly in the migrated data.
  • Can't fully bring over (10 kinds) — but each one warns you instead of failing silently. When migration hits data it can't fully handle, it prints a named warning and (with --strict) stops the whole thing, so nothing gets dropped without you knowing. All of these fired on real runs: tables with images inside cells, artifact version history, "which run used which artifact", oversized artifacts, media skipped by --no-files, over-long text values, hand-made charts wandb won't share the recipe for, and exotic file types (molecule / bokeh / joined-table). (Two more warnings only trigger on a random download failure, so those are covered by tests — one also happened for real.)

Data integrity — 0 loss. Every one of the 740/740 runs landed in every migration variant; ClickHouse metric-row counts match the staged export exactly (5,320 = 5,320) across load / all / cleanup. Frontend spot-checks confirm boxes, mixed-dtype tables (int / unicode / float / bool / null), and all 80 sweeps (grid / bayes / random + failed → "stopped early") render.

Performance. Measured two ways, both --workers 4:

  • Original — 740 runs on localhost, migrated in batches of 50 separate invocations. This was a workaround: the client leaked one background upload process per run, so a single big invocation piled up hundreds of processes and could exhaust RAM. Batching (each invocation exits and reaps its processes) hid it.
  • New — the full 771 runs (now including street-scene detection/segmentation) migrated against the real dev endpoints (prod DB) as a single whole-project call, no batching. Safe now because the leak is fixed — each run's upload process is terminated as soon as its data is flushed, so live process count stays flat (~2–4) no matter how many runs. ~22 GB RAM stayed free throughout; 771/771 loaded, 0 failed, no crash.
Measurement localhost · 740 · batched dev/prod · 771 · single call
export 27m 07s 26m 08s
load 19m 52s 27m 32s
all (export + load together) 32m 02s 29m 55s
  • Export is ~the same either way — it's wandb-download-bound, so the destination endpoint doesn't matter.
  • Load is slower against dev (27m vs 20m): every upload now crosses the public internet to pluto-ingest-dev instead of the loopback.
  • all overlaps export + load to save ~1/3 vs running them separately, on both.
  • --cleanup (localhost): same wall time, staged disk 1.3 G → 128 K.
  • More workers barely help on one project (1w ≈ 4w on localhost): --workers only splits work across projects, and runs load one at a time within a project regardless. It pays off migrating many file-heavy projects.

(One machine, one run each — treat exact minutes loosely; the patterns are the point. Both migrations: 0 failed, nothing lost across any data type, no crash. The single-call dev run is the real headline — the whole project migrates in one command against production, no batching needed.)

Ubuntu and others added 2 commits August 5, 2026 20:10
… config)

Migrated segmentation masks rendered blank because they arrived without
class_labels (the id->name colour key). Root cause: wandb stores mask
class_labels in the run *config* (_wandb.value["mask/class_labels"], keyed
'<image>_wandb_delimeter_<layer>'), NOT in the mask media descriptor that
scan_history returns — and separately, the loader was dropping class_labels
from the staged mask ref (kept only the path).

Fix, both on the pluto side (no server-private change needed — the #559
frontend already paints class_labels; native masks proved it):
- exporter: recover class_labels from run config and fold them into each
  mask layer's annotation.
- loader: carry class_labels through to the uploaded mask spec.

Verified end-to-end: a wandb-imported mask now renders identically to a
native pluto.Image(masks=). Adds exporter + loader unit tests; also lands a
previously-pending artifact-download-failed export test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PR #131 summary now embeds screenshots via GitHub user-attachments, so
the committed .github/pr131/*.png copies (and loose .github screenshots) are
unused. Removes ~3.8 MB of tracked images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

Copy link
Copy Markdown

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 0f61ca8. Configure here.

Comment thread pluto/migrate/loader.py
Comment thread pluto/migrate/cli.py
Comment thread pluto/migrate/cli.py
…led restores, attempt failed runs once

Three defects in the load/all failure path (flagged in review of 0f61ca8):

1. loader: on a collision-restore where finish() throws, the run was still
   marked loaded and skipped — stranding it RUNNING server-side and skipping
   it forever. Now it's reported as failed (not marked loaded), so a later
   run retries it.

2/3. all: a run that failed one poll pass was re-attempted every subsequent
   pass (re-running identical staged data can't help and risks duplicating
   media on a mid-replay resume), and once added to the reported-failures set
   it could never be cleared. PlutoLoader now takes skip_run_ids; _all_one_project
   feeds already-failed run-ids into it, so each failure is at-most-once and the
   reported failure count stays exact.

The loader only ever sees a run once its export sentinel is written, so a load
failure is genuine (complete data) rather than "needs more time" — attempt-once
is safe; the user re-runs all/load to retry (in-progress runs resume from the
ledger).

Adds regression tests: restore-failure is reported not marked loaded,
skip_run_ids bypasses a run without attempting it, and all feeds failed
run-ids into skip_run_ids on later passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

Copy link
Copy Markdown

RUNS TABLE COMPARISON IN STAGING:

Screen.Recording.2026-08-07.at.12.21.06.AM.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants