Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
2c25ae7
feat(op): timestamp backfill support — log(timestamp=), _log_console,…
Jul 7, 2026
ec9bb5f
feat(migrate): parquet staging schema and resume state modules
Jul 7, 2026
b7f2c04
feat(migrate): WandbExporter — stage wandb cloud runs as parquet + files
Jul 7, 2026
1e37ed8
feat(migrate): PlutoLoader — replay staged exports into Pluto
Jul 7, 2026
b9dfde8
feat(migrate): pluto migrate wandb export|load|all CLI
Jul 7, 2026
2809512
test(migrate): staging integration tests for the migration pipeline
Jul 7, 2026
2febb8f
style: ruff format migrate modules and tests
Jul 7, 2026
641460f
fix(migrate): apply code-review findings
Jul 7, 2026
543364a
docs: regenerate API docs for log(timestamp=)
Jul 7, 2026
074e57b
fix(migrate): mypy errors — typed row base and part-path narrowing
Jul 7, 2026
d7f8792
ci: retrigger flaky test (3.12) prod-401 flake
Jul 7, 2026
45cbc12
ci: retrigger prod-401 flake (round 2)
Jul 7, 2026
46e52ce
fix(auth): transient login validation failure must not clobber provid…
Jul 7, 2026
8c24c91
Merge remote-tracking branch 'origin/main' into feat/wandb-migrate
Jul 22, 2026
91d9303
feat(migrate): send historical statusUpdated on finish for backfilled…
Jul 22, 2026
d650b36
fix(migrate): harden exporter/loader against silent data loss and bat…
Jul 23, 2026
7e5e554
fix(migrate): histogram null-bins, per-row resilience, resume interru…
Jul 23, 2026
3ca09b1
fix(migrate): preserve multi-sample media order; scope load cache by …
Jul 23, 2026
42db62e
feat(migrate): surface non-migrated data (coverage + warnings + --str…
Jul 23, 2026
6017b3e
fix(migrate): handle wandb media lists and non-finite string metrics
Jul 24, 2026
5c2f648
fix(migrate): reconstruct real histogram bin edges from wandb packedBins
Jul 24, 2026
00d4e1a
perf(migrate): download wandb files concurrently (scale bottleneck)
Jul 24, 2026
af3db02
feat(migrate): pipeline export+load in `all`; add --cleanup to free s…
Jul 24, 2026
5ff94ae
feat(migrate): migrate a whole wandb account with one command
Jul 27, 2026
d1161db
fix(migrate): announce when --workers is capped to the project count
Jul 27, 2026
11666e0
fix(migrate): CI failures + review-bot findings
Jul 27, 2026
5637d5f
fix(migrate): close review gaps (strict coverage, manifest race, all …
Jul 27, 2026
2b96110
feat(migrate): flag sweep membership + artifact lineage/versioning
Jul 28, 2026
0ceceb7
fix(migrate): lower default --workers from 8 to 4 (memory safety)
Jul 29, 2026
246e3a2
fix(migrate): restore terminal status when skipping an already-migrat…
Jul 29, 2026
07ad4d1
fix(migrate): self-heal wandb cache corruption from crashes
Jul 29, 2026
62a43bb
test(pr131): image-embed test asset
Jul 30, 2026
6518830
docs(pr131): wandb-vs-pluto side-by-side screenshots for migration re…
Jul 30, 2026
21af8c1
docs(pr131): expand wandb metrics; select actual files (table json, h…
Jul 30, 2026
8c5a52c
docs(pr131): cache-bust fixed metrics + table-file shots
Jul 30, 2026
43d5ad4
docs(pr131): move custom-charts to limitations; add proven also-migra…
Jul 30, 2026
96d5b89
docs(pr131): plotly/html3d + system-metrics -> limitations; fix artif…
Jul 30, 2026
b9f1e8a
feat(migrate): migrate wandb string-series and custom-chart panel specs
Jul 31, 2026
344bd47
feat(migrate): forward full custom-chart stringSettings + more preset…
Jul 31, 2026
93dfac6
feat(client): log native string metrics as string-series (no cardinal…
Aug 1, 2026
ba6d69d
feat(sweep): native hyperparameter sweeps + wandb sweep migration
Aug 1, 2026
59b4491
feat(sweep): resume + bayesian search (optuna)
Aug 1, 2026
7225ba9
feat(sweep): stamp declared sweep spec on native runs (config.sweep)
Aug 1, 2026
35b917d
feat(image): send image annotations (bounding boxes) — native + migra…
Aug 2, 2026
480de90
feat(image): send segmentation masks (native + migration)
Aug 2, 2026
bac370b
fix(deps): regenerate poetry.lock for optuna (sweep bayes)
Aug 3, 2026
1f8cea8
test(migrate): characterize media-in-table degradation
Aug 3, 2026
898aa5d
fix(op): lazy-init _latest_metrics; regen API docs for sweep + image …
Aug 3, 2026
ca6dfa2
feat(migrate): flag tables with media cells (table-media-cell)
Aug 3, 2026
71b89f0
test(migrate): cover unsupported(<type>) and string-series-too-long f…
Aug 4, 2026
05ffd57
feat(migrate): render migrated wandb masks (recover class_labels from…
Aug 5, 2026
0f61ca8
chore(.github): drop repo-hosted PR screenshots
Aug 5, 2026
a8f7612
fix(migrate): correct failure handling in load/all — don't strand fai…
Aug 5, 2026
3bb5291
fix(sync): terminate sync subprocess on normal stop() so bulk loads d…
Aug 7, 2026
b6db926
fix(migrate): carry per-image boxes/masks for galleries (images/separ…
Aug 7, 2026
b1e1ddb
fix(migrate): don't silently strand runs when the terminal status upd…
Aug 11, 2026
3000597
feat(migrate): verify terminal status after load and heal stale-run r…
Aug 12, 2026
e4eb461
fix(migrate): address 3 Bugbot findings — heal status check, date fil…
Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs-api/media.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ File(path: str, name: Optional[str] = None, **kwargs) -> None
Image(
data: Union[str, PILImage.Image, np.ndarray, bytes, bytearray],
caption: Optional[str] = None,
boxes: Optional[Dict[str, Any]] = None,
masks: Optional[Dict[str, Any]] = None,
annotations: Optional[Union[str, Dict[str, Any]]] = None,
) -> None
```

Expand Down
2 changes: 2 additions & 0 deletions docs-api/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"Table",
"Text",
"Video",
"agent",
"alert",
"finish",
"generate_run_id",
Expand All @@ -23,6 +24,7 @@
"logout",
"query",
"setup",
"sweep",
"watch"
],
"pages": [
Expand Down
8 changes: 7 additions & 1 deletion docs-api/run.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@ log(
data: Dict[str, Any],
step: Union[int, None] = None,
commit: Union[bool, None] = None,
timestamp: Optional[float] = None,
) -> None
```

Log run data
Log run data.

`timestamp` is the wall-clock time of the data points in epoch
seconds (`time.time()` style) and defaults to now. The server
stores it as-is, so backfill/migration tooling can preserve
historical times. Invalid values fall back to now with a warning.

---

Expand Down
3 changes: 3 additions & 0 deletions pluto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .file import Artifact, Audio, File, Image, Text, Video
from .init import finish, init
from .sets import Settings, setup
from .sweep import agent, sweep
from .sys import System
from .util import generate_run_id

Expand Down Expand Up @@ -39,6 +40,8 @@
'setup',
'query',
'generate_run_id',
'sweep',
'agent',
)

__version__ = '0.0.27'
Expand Down
9 changes: 9 additions & 0 deletions pluto/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ def main():
help='show detailed sync progress',
)

# `pluto migrate wandb export|load|all` — import historical data.
# cli.py keeps wandb/pyarrow imports inside the handlers, so this
# is safe without the 'migrate' extra installed.
from pluto.migrate.cli import add_migrate_parser, cmd_migrate

add_migrate_parser(subparsers)

args = parser.parse_args()

if args.version:
Expand All @@ -259,6 +266,8 @@ def main():
logout()
elif args.command == 'sync':
_cmd_sync(args)
elif args.command == 'migrate':
sys.exit(cmd_migrate(args))
else:
parser.print_help()
sys.exit(1)
Expand Down
8 changes: 8 additions & 0 deletions pluto/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ def make_compat_status_v1(settings, trace=None):
'status': STATUS[settings._op_status],
# "metadata": json.dumps(settings.meta),
'statusMetadata': json.dumps(trace) if trace is not None else None,
# Historical terminal-status time (epoch ms) for backfilled/migrated
# runs (pluto.migrate). Server applies it to the durable
# `statusUpdated` column so Duration = end - createdAt is correct;
# duration's `end` reads `statusUpdated ?? updatedAt`, and updatedAt
# re-bumps via Prisma @updatedAt, so statusUpdated is the field that
# must stick. None for normal runs (empty compat) -> server keeps
# now(), so behavior is unchanged.
'statusUpdated': settings.compat.get('updatedAt'),
}
).encode()

Expand Down
7 changes: 6 additions & 1 deletion pluto/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ def login(settings=None, retry=False):
)
except Exception as e:
tlogger.warning(f'{tag}: server not reachable; reason: {e}')
settings._auth = '_key'
# A transient failure of this best-effort validation POST must not
# corrupt an explicitly provided token: overwriting it with the
# '_key' sentinel makes every later request send 'Bearer _key',
# which servers reject as 401 "Invalid API key" for the whole run.
if not auth_was_provided:
settings._auth = '_key'
try:
r.raise_for_status()
body = r.json()
Expand Down
108 changes: 108 additions & 0 deletions pluto/file.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import hashlib
import io
import json
import logging
import mimetypes
import os
Expand Down Expand Up @@ -56,6 +58,13 @@ class File:
# override this instance attribute in their __init__; the class-level default
# ensures it always exists (e.g. on a directly-constructed File).
_caption: Optional[str] = None
# Optional opaque JSON string of image annotations (wandb-shape boxes/masks),
# sent to the server as mlop_files.annotations. Only Image sets it.
_annotations: Optional[str] = None
# Optional override for the upload payload's fileType (e.g. "mask" for a
# segmentation-mask PNG, so the frontend hides it from the media grid).
# None → fileType is derived from the extension as usual.
_upload_file_type: Optional[str] = None

def __init__(
self,
Expand Down Expand Up @@ -225,12 +234,25 @@ def __init__(
self,
data: Union[str, 'PILImage.Image', np.ndarray, bytes, bytearray],
caption: Optional[str] = None,
boxes: Optional[Dict[str, Any]] = None,
masks: Optional[Dict[str, Any]] = None,
annotations: Optional[Union[str, Dict[str, Any]]] = None,
) -> None:
self._name = caption + f'.{uuid.uuid4()}' if caption else f'{uuid.uuid4()}'
# Preserve the raw caption separately so it can be sent to the server
# as a dedicated field (mlop_files.caption); _name keeps the legacy
# caption-as-filename behavior for back-compat with older servers.
self._caption = caption
# Image annotations (wandb-shape boxes/masks) → mlop_files.annotations.
# ``annotations`` is a ready JSON blob (raw string/dict) — used by the
# wandb migration, which forwards wandb's own {boxes, masks} verbatim.
# ``boxes`` ({layer: {box_data, class_labels}}) is folded in with each box
# defaulted to domain="pixel". ``masks`` ({layer: {mask_data|path,
# class_labels}}) each become a separate PNG uploaded as fileType "mask"
# and referenced by fileName; the mask PNGs to upload alongside are
# collected in ``_annotation_files``.
self._annotation_files: list = []
self._annotations = self._build_annotations(annotations, boxes, masks)
self._id = f'{uuid.uuid4()}{uuid.uuid4()}'.replace('-', '')
self._ext = '.png'
self._image: Any = None
Expand Down Expand Up @@ -264,6 +286,92 @@ def __init__(
logger.debug(f'{self.tag}: attempted conversion from array')
self._image = make_compat_image_numpy(data)

def _build_annotations(
self,
annotations: Optional[Union[str, Dict[str, Any]]],
boxes: Optional[Dict[str, Any]],
masks: Optional[Dict[str, Any]],
) -> Optional[str]:
"""Assemble the annotations JSON string sent to the server.

``annotations`` (a ready JSON string/dict, wandb's {boxes, masks} shape)
is forwarded as-is; ``boxes`` is folded into ``annotations.boxes`` with
each box defaulted to ``domain: "pixel"``; ``masks`` each become a PNG
(uploaded separately as fileType "mask") referenced by ``fileName``.
Returns None when there is nothing to attach.
"""
# Ready JSON string with nothing to merge → forward verbatim.
if isinstance(annotations, str) and not boxes and not masks:
return annotations or None
result: Dict[str, Any] = {}
if isinstance(annotations, str):
try:
parsed = json.loads(annotations)
result = parsed if isinstance(parsed, dict) else {}
except (ValueError, TypeError):
result = {}
elif isinstance(annotations, dict):
result = dict(annotations)
if boxes:
merged = dict(result.get('boxes') or {})
for layer, spec in boxes.items():
if not isinstance(spec, dict):
continue
out = dict(spec)
box_data = out.get('box_data')
if isinstance(box_data, list):
out['box_data'] = [
{'domain': 'pixel', **b} if isinstance(b, dict) else b
for b in box_data
]
merged[layer] = out
if merged:
result['boxes'] = merged
if masks:
merged_masks = dict(result.get('masks') or {})
for layer, spec in masks.items():
entry = self._stage_mask(spec)
if entry is not None:
merged_masks[layer] = entry
if merged_masks:
result['masks'] = merged_masks
return json.dumps(result) if result else None

def _stage_mask(self, spec: Any) -> Optional[Dict[str, Any]]:
"""Turn one mask-layer spec into an uploadable PNG + its annotations ref.

``spec`` is ``{mask_data: <HxW class-id array>}`` (native — encode a PNG
with the class id in the red channel) or ``{path: <mask.png>}``
(migration — forward wandb's mask file). The PNG is queued in
``_annotation_files`` (uploaded as fileType "mask"); returns
``{fileName, class_labels?}`` for ``annotations.masks[layer]``.
"""
if not isinstance(spec, dict):
return None
png: Union[str, bytes, None] = None
if spec.get('path'):
png = spec['path'] # migration: an existing mask PNG on disk
elif spec.get('mask_data') is not None:
arr = np.asarray(spec['mask_data']).astype('uint8')
zeros = np.zeros_like(arr)
rgb = np.stack([arr, zeros, zeros], axis=-1) # class id → red channel
buf = io.BytesIO()
PILImage.fromarray(rgb, 'RGB').save(buf, format='PNG')
png = buf.getvalue()
if png is None:
return None
mask_name = f'{uuid.uuid4()}.mask'
mask_img = Image(png)
mask_img._name = mask_name # uploaded fileName = f'{mask_name}.png'
# Marks the upload's fileType as "mask" so the frontend hides it from the
# media grid and resolves it from annotations by fileName.
mask_img._upload_file_type = 'mask'
self._annotation_files.append(mask_img)
entry: Dict[str, Any] = {'fileName': f'{mask_name}.png'}
if spec.get('class_labels'):
entry['class_labels'] = spec['class_labels']
return entry

def load(self, dir: Optional[str] = None) -> None:
if not self._path:
if dir:
Expand Down
56 changes: 49 additions & 7 deletions pluto/iface.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,23 @@ def close(self) -> None:
self.client_api.close()

def update_status(self, trace: Union[Any, None] = None) -> None:
"""Update run status on the server (called at finish)."""
"""Update run status on the server (called at finish).

This is the run's terminal state transition — losing it strands the
run in RUNNING (the UI then renders it as FAILED with an ever-growing
duration). So, unlike fire-and-forget uploads/heartbeats, it retries
transient connection resets and raises ``PlutoRequestError`` if it
still can't confirm the update, so the caller (e.g. the migration
loader) knows the finish was not recorded.
"""
self._post_v1(
self.settings.url_stop,
self.headers,
make_compat_status_v1(self.settings, trace),
client=self.client_api,
name='status',
raise_on_error=True,
retry_connection_errors=True,
)

def update_tags(self, tags: List[str]) -> None:
Expand Down Expand Up @@ -300,6 +311,7 @@ def _try(
timeout: Optional[float] = None,
suppress_httpx_logs: bool = False,
raise_on_error: bool = False,
retry_connection_errors: bool = False,
):
effective_max_retries = (
max_retries
Expand Down Expand Up @@ -336,8 +348,17 @@ def _try(
# former carries a server-provided reason worth raising. last_status
# is threaded down from the last HTTP response so the exception
# reports the real code (e.g. 500) rather than None.
if raise_on_error and error_info.startswith('HTTP '):
raise PlutoRequestError(error_info, status_code=last_status)
# A persistent server error carries a server-provided reason worth
# raising. When retry_connection_errors is set (critical one-shot
# requests like the terminal status update), also raise on an
# exhausted network failure so the caller can't mistake a dropped
# request for success.
if raise_on_error and (
error_info.startswith('HTTP ') or retry_connection_errors
):
raise PlutoRequestError(
error_info or 'request failed', status_code=last_status
)

return None

Expand Down Expand Up @@ -401,16 +422,34 @@ def _try(
httpx.RemoteProtocolError,
httpx.LocalProtocolError,
) as e:
# Treat connection errors as shutdown signals - don't retry
# This prevents hanging during atexit when sockets are being torn down
if not retry_connection_errors:
# Default: treat connection errors as shutdown signals - don't
# retry. This prevents hanging during atexit when sockets are
# being torn down (heartbeat / trigger / streaming-upload spam).
logger.debug(
'%s: %s: connection error (likely shutdown): %s: %s',
tag,
name,
type(e).__name__,
e,
)
return None
# Critical one-shot request (e.g. the terminal status update): a
# keep-alive socket dropped mid-request is transient, NOT a shutdown
# signal. Fall through to the retry/backoff path below so a single
# reset can't silently strand the run's finished status on the
# server (which then shows the run as stuck / FAILED forever).
error_info = f'{type(e).__name__}: {str(e)}'
logger.debug(
'%s: %s: connection error (likely shutdown): %s: %s',
'%s: %s: attempt %s/%s: connection error from %s: %s: %s',
tag,
name,
retry + 1,
effective_max_retries + 1,
url,
type(e).__name__,
e,
)
return None
except Exception as e:
# Capture error info for potential failure logging
error_info = f'{type(e).__name__}: {str(e)}'
Expand Down Expand Up @@ -446,6 +485,7 @@ def _try(
timeout=timeout,
suppress_httpx_logs=suppress_httpx_logs,
raise_on_error=raise_on_error,
retry_connection_errors=retry_connection_errors,
)

def _put_v1(
Expand Down Expand Up @@ -481,6 +521,7 @@ def _post_v1(
timeout: Optional[float] = None,
suppress_httpx_logs: bool = False,
raise_on_error: bool = False,
retry_connection_errors: bool = False,
):
# Support both queue and direct content
if isinstance(q, queue.Queue):
Expand All @@ -503,6 +544,7 @@ def _post_v1(
timeout=timeout,
suppress_httpx_logs=suppress_httpx_logs,
raise_on_error=raise_on_error,
retry_connection_errors=retry_connection_errors,
)

if (
Expand Down
Loading