IB-WP-0014: archive-list, restore, retention annotation, docs (T03-T05)

Round out IB-WP-0014 with the remaining archive operations and docs.

- restore_archive() and `infospace-bench restore <pkg> --target <dir>` round-trip
  a finalized package's bytes back to disk. Refuses to overwrite a non-empty
  target unless --force. --from <infospace-root> resolves the store location.
- archive-list CLI with --with-retention flag; annotate_retention() opens the
  per-infospace registry and joins each record with its current retention
  state (effective class, expires, holds, eligibility).
- docs/archive-integration.md covers when to archive, the include set,
  retention classes, storage layout, credentials policy, and the explicit
  non-goal that S3/git backends live in artifact-store.
- SCOPE.md cross-links the new doc.
- Workplan flipped to status: done. Full pytest suite: 72 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 11:46:23 +02:00
parent e343443d77
commit ddefd69f71
8 changed files with 636 additions and 2 deletions

View File

@@ -6,6 +6,12 @@ import json
import sys
from pathlib import Path
from .archive import (
annotate_retention,
archive_infospace,
list_archives,
restore_archive,
)
from .checks import run_collection_checks
from .engine import engine_capability_contract, plan_asset_sync, sync_assets
from .errors import InfospaceError
@@ -195,6 +201,74 @@ def build_parser() -> argparse.ArgumentParser:
generate_from_source.add_argument("--max-chunks", type=int, default=0)
generate_from_source.add_argument("--apply", action="store_true")
archive = sub.add_parser(
"archive",
help="Archive an infospace into artifact-store (durable, content-addressed)",
)
archive.add_argument("root")
archive.add_argument(
"--retention-class",
default="release-evidence",
help="artifact-store retention class id (default: release-evidence)",
)
archive.add_argument(
"--include",
action="append",
default=[],
help="Relative path to include (repeatable). Default: infospace.yaml, artifacts/, workflows/, output/, reports/, exports/",
)
archive.add_argument(
"--exclude",
action="append",
default=[],
help="Relative path or glob to exclude (repeatable)",
)
archive.add_argument("--note", default="", help="Free-text note for the archive record")
archive.add_argument(
"--store-root",
default="",
help="Override the artifact-store location (default: <root>/output/archives/.store)",
)
archive_list = sub.add_parser(
"archive-list",
help="List recorded archives for an infospace",
)
archive_list.add_argument("root")
archive_list.add_argument(
"--with-retention",
action="store_true",
help="Annotate each archive with its current retention state",
)
archive_list.add_argument(
"--store-root",
default="",
help="Override the artifact-store location for retention lookups",
)
restore = sub.add_parser(
"restore",
help="Restore an archived infospace package into a target directory",
)
restore.add_argument("package_id")
restore.add_argument("--target", required=True, help="Directory to restore into")
restore.add_argument(
"--from",
dest="from_root",
default="",
help="Source infospace whose archive store holds the package",
)
restore.add_argument(
"--store-root",
default="",
help="Direct path to the artifact-store location",
)
restore.add_argument(
"--force",
action="store_true",
help="Overwrite into a non-empty target directory",
)
engine = sub.add_parser("engine", help="Inspect and sync engine boundary state")
engine_sub = engine.add_subparsers(dest="engine_command", required=True)
@@ -423,6 +497,36 @@ def main(argv: list[str] | None = None) -> int:
_write_json(plan_generation(infospace.root, stage=args.stage))
else:
parser.error(f"Unhandled generate command: {args.generate_command}")
elif args.command == "archive":
record = archive_infospace(
Path(args.root),
retention_class=args.retention_class,
include=args.include or None,
exclude=args.exclude or None,
note=args.note,
store_root=args.store_root or None,
)
_write_json(record.to_dict())
elif args.command == "archive-list":
archives = list_archives(Path(args.root))
if args.with_retention:
payload = annotate_retention(
archives,
store_root=args.store_root or None,
source_infospace=Path(args.root) if not args.store_root else None,
)
_write_json({"archives": payload})
else:
_write_json({"archives": [rec.to_dict() for rec in archives]})
elif args.command == "restore":
result = restore_archive(
args.package_id,
target=Path(args.target),
store_root=args.store_root or None,
source_infospace=Path(args.from_root) if args.from_root else None,
force=args.force,
)
_write_json(result.to_dict())
elif args.command == "engine":
if args.engine_command == "inspect":
_write_json(