Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6ffa6e1
fix(storage): picker-aware no-project scope notice (operator 2026-10-…
ywatanabe1989 Oct 24, 2026
578b8df
fix(storage): audit-green the scope-notice slice (STX-TQ007 single-as…
ywatanabe1989 Oct 24, 2026
0d3849d
fix(gui): zero margins on the largest div (operator 2026-10-24)
ywatanabe1989 Oct 24, 2026
7662c8c
feat(gui): interactive usage sunburst replaces static doughnuts (oper…
ywatanabe1989 Oct 24, 2026
28c3dfc
feat(gui): duplicates as an app + usage sunburst + zero margins (oper…
ywatanabe1989 Oct 24, 2026
2333764
feat(gui): browser move-apply with explicit confirm + can_edit gate (…
ywatanabe1989 Oct 24, 2026
88aeaa0
feat(transfer): restorable retention for verified moves (operator 202…
ywatanabe1989 Oct 24, 2026
9685294
feat(transfer): safe-move retention + sweep-restorable CLI (operator …
ywatanabe1989 Oct 24, 2026
ac2e613
docs(storage): currency for the operator 2026-10-24 batch (CHANGELOG,…
ywatanabe1989 Oct 24, 2026
82269a0
fix(gui): apply-form checkboxes use shared SDK primitive (operator 20…
ywatanabe1989 Oct 24, 2026
599bb72
chore(release): cut 0.6.13 (operator 2026-10-24 UI batch)
ywatanabe1989 Oct 24, 2026
50d7734
fix(gui): drop per-folder breakdown section, sunburst resolves all (o…
ywatanabe1989 Oct 24, 2026
b514cb5
fix(transfer): sweep_restorable dry-runs by default (safety contract)
ywatanabe1989 Oct 24, 2026
005ab1a
docs(storage): quick-start covers retire/sweep + move-vs-backup + tab…
ywatanabe1989 Oct 24, 2026
c7d5d3c
fix(gui): picker include requires configured provider, not just tag l…
ywatanabe1989 Oct 25, 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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.6.13] - 2026-10-24

### Added

- Interactive usage sunburst (`usage-sunburst/` route): zoomable,
bookmarkable folder breakdown replacing both static doughnuts; project
scope shows only the selected project's bytes and hides volume cards.
- Browser move-apply (`move-apply/`): explicit confirm checkbox, optional
content re-hash, project scope enforces edit rights, transport
pre-flight, bounded run; result card names the verification.
- Restorable safe move: `apply_archive(retire_source=True)` stages the
verified source aside (atomic same-filesystem rename) instead of
deleting; `sweep-restorable` CLI verb deletes expired staging after the
7-day retention; manifests record `staged_path`; `--retire-source` flag
on `archive`.
- Duplicates tab as an app: unavailable state shows the would-scan roots
and what the report holds instead of only a command.

### Changed

- Zero margins on the largest div: app content fills the pane edge to
edge (live dock clearance kept).
- No-project scope notice names the hub-mounted requirement where no
header picker exists.
- Archive report names staged restorable sources instead of reading as a
backup retain or a delete.

### Fixed

- Docs drift (7 fixes, all evidenced by the code): Backup named as the
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
| 2 | **Inodes exhausted, disk looks free** — `No space left on device` with GBs free: `du` measures bytes, not the millions of tiny files starving an HPC quota | **The `FILES` column** — every child's inode count, and `--sort files` to rank by it, so an inode hog surfaces even when it's small on disk |
| 3 | **Stale-build cleanup deletes a live image** — a directory of dated images (SIFs, tarballs) where the currently-live file is often the oldest-looking one still symlinked in | **`scitex-storage images prune`** — rotates to the newest N, but a file any symlink in the directory currently resolves to is never a candidate, regardless of age. Dry-run by default |
| 4 | **HPC fileset hits its inode quota** — millions of small files on GPFS; deleting real data is not an option, and the fix (tar it) reads file content, barred from login nodes | **`scitex-storage sweep`** — tars an inode-hog directory in place, compute-node-only (needs `$SLURM_JOB_ID`), explicit per-directory `--confirm`, skips anything still active |
| 5 | **Copy-then-delete risks silent loss** — local disk fills with directories that should move to NAS, but "just delete after copying" risks a partial/failed copy silently losing data | **`scitex-storage archive`** — copy-verify-then-remove over ssh to `nas`/`nas2` (scitex-ssh's `sync_dir`), checksummed by default, manifest written first, `restore` reads it back |
| 5 | **Copy-then-delete risks silent loss** — local disk fills with directories that should move to NAS, but "just delete after copying" risks a partial/failed copy silently losing data | **`scitex-storage archive`** — copy-verify-then-remove over ssh to `nas`/`nas2` (scitex-ssh's `sync_dir`), checksummed by default, manifest written first, `restore` reads it back; `--retire-source` stages the original restorable instead of deleting (swept by `sweep-restorable` after retention) |
| 6 | **Duplicate copies pile up** — `dataset (1).zip`, `dataset_final_v2.zip`, and other near-identical names crowding the same directory | **`scitex-storage find-duplicates`** — an explicitly opt-in, separate verb (via `fclones`) that hashes file contents to report exact-duplicate groups; kept out of `scan` on purpose (see below) |

## Quick Start
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "scitex-storage"
version = "0.6.12"
version = "0.6.13"
description = "Research-data storage triage: read-only stat-only scan + referenced-file-aware rotation of versioned build artifacts (e.g. SIF images)"
readme = "README.md"
license = "AGPL-3.0-only"
Expand Down
8 changes: 8 additions & 0 deletions src/scitex_storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"apply_sweep": "_transfer._sweep",
"plan_sweep": "_transfer._sweep",
"sweep_status": "_transfer._sweep",
"StagedRecord": "_transfer._retention",
"SweepReport": "_transfer._retention",
"stage_for_retention": "_transfer._retention",
"sweep_restorable": "_transfer._retention",
"ArchiveManifest": "_transfer._archive",
"ArchivePlan": "_transfer._archive",
"RestorePlan": "_transfer._archive",
Expand Down Expand Up @@ -142,6 +146,10 @@ def __dir__() -> list[str]:
"apply_sweep",
"plan_sweep",
"sweep_status",
"StagedRecord",
"SweepReport",
"stage_for_retention",
"sweep_restorable",
"ArchiveManifest",
"ArchivePlan",
"RestorePlan",
Expand Down
12 changes: 12 additions & 0 deletions src/scitex_storage/_cli/_archive_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ def _require_rsync() -> None:
is_flag=True,
help="Preview only (explicit spelling of the default -- no side effects either way).",
)
@click.option(
"--retire-source",
"retire_source",
is_flag=True,
default=False,
show_default=True,
help="After a verified sync, move the local source to restorable "
"staging instead of deleting it (swept after retention; see "
"sweep-restorable).",
)
@click.option(
"--yes",
"-y",
Expand All @@ -138,6 +148,7 @@ def archive_cmd(
checksum: bool,
verify_content_too: bool,
dry_run: bool,
retire_source: bool,
confirmed: bool,
as_json: bool,
) -> None:
Expand Down Expand Up @@ -202,6 +213,7 @@ def archive_cmd(
checksum=checksum,
exclude=exclude_patterns,
verify_content_too=verify_content_too,
retire_source=retire_source,
)
if do_apply
else None
Expand Down
1 change: 1 addition & 0 deletions src/scitex_storage/_cli/_lazy.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"images": "._images_cmd:images_group",
"sweep": "._sweep_cmd:sweep_cmd",
"sweep-status": "._sweep_cmd:sweep_status_cmd",
"sweep-restorable": "._retention_cmd:sweep_restorable_cmd",
"archive": "._archive_cmd:archive_cmd",
"backup": "._backup_cmd:backup_cmd",
"restore": "._archive_cmd:restore_cmd",
Expand Down
96 changes: 96 additions & 0 deletions src/scitex_storage/_cli/_retention_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""``scitex-storage sweep-restorable`` — delete expired restorable staging.

Staged trees (verified moves whose source was moved aside instead of
deleted) become eligible for deletion once their retention expires
(default 7 days at stage time). This verb lists the expired ones by
default and deletes them only with ``--yes`` -- never a blanket delete,
and never anything the guards refuse (outside a ``.stx-restorable``
directory, or a tree whose marker no longer matches).
"""

from __future__ import annotations

import json

import click

from .._transfer._retention import sweep_restorable
from ._compat import spec_command_kwargs


@click.command(
"sweep-restorable",
**spec_command_kwargs(
summary="Delete restorable-staged trees whose retention expired.",
description=(
"Lists staged trees (verified moves kept restorable instead of "
"deleted) whose 7-day retention has expired. Defaults to a "
"dry-run listing; --yes deletes exactly the expired, guarded "
"trees. Entries outside a `.stx-restorable` directory or whose "
"marker no longer matches are refused, never deleted. Run "
"periodically (cron) so staged originals do not accumulate."
),
examples=(
("{prog} sweep-restorable", "dry-run, list expired staging"),
(
"{prog} sweep-restorable --yes",
"delete exactly the expired staged trees",
),
),
),
)
@click.option(
"--dry-run",
"dry_run",
is_flag=True,
default=False,
help="List expired staging without deleting (the default behavior too).",
)
@click.option(
"--yes",
"-y",
"assume_yes",
is_flag=True,
default=False,
help="Actually delete the expired staged trees.",
)
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.")
def sweep_restorable_cmd(dry_run: bool, assume_yes: bool, as_json: bool) -> None:
# --yes is the only thing that deletes: without it this is a dry-run
# listing, whether --dry-run was spelled out or not.
report = sweep_restorable(dry_run=not assume_yes)
if as_json:
click.echo(
json.dumps(
{
"removed": report.removed,
"retained": report.retained,
"pruned": report.pruned,
"refused": report.refused,
"errors": report.errors,
},
indent=2,
)
)
return
if assume_yes:
for path in report.removed:
click.echo(f"removed {path}")
elif report.removed:
for path in report.removed:
click.echo(f"expired (dry-run) {path}")
else:
click.echo("no expired staging.")
for path in report.retained:
click.echo(f"retained (not yet expired) {path}")
for path in report.pruned:
click.echo(f"pruned missing record {path}")
for path in report.refused:
click.echo(f"REFUSED (not provably staged) {path}")
for path, error in report.errors.items():
click.echo(f"error {path}: {error}")


# EOF
Loading
Loading