Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions requirements/SWR-APPLICATION-2-17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
itemId: SWR-APPLICATION-2-17
itemTitle: Update Run and Item Custom Metadata After Creation
itemHasParent: SHR-APPLICATION-2
itemType: Requirement
Requirement type: FUNCTIONAL
Layer: System (backend logic)
---

System shall allow users to update the custom metadata of an existing application run, and of an individual item within a run, after creation with optional optimistic concurrency control.
62 changes: 61 additions & 1 deletion specifications/SPEC-APPLICATION-SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
itemId: SPEC-APPLICATION-SERVICE
itemTitle: Application Module Specification
itemType: Software Item Spec
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-3, SWR-APPLICATION-2-4, SHR-APPLICATION-3, SWR-APPLICATION-2-12, SWR-APPLICATION-2-11, SWR-APPLICATION-2-13, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-5, SWR-APPLICATION-2-7, SWR-APPLICATION-2-8, SWR-APPLICATION-2-9, SWR-APPLICATION-3-3
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-3, SWR-APPLICATION-2-4, SHR-APPLICATION-3, SWR-APPLICATION-2-12, SWR-APPLICATION-2-11, SWR-APPLICATION-2-13, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-17, SWR-APPLICATION-2-5, SWR-APPLICATION-2-7, SWR-APPLICATION-2-8, SWR-APPLICATION-2-9, SWR-APPLICATION-3-3
Module: Application
Layer: Domain Service
Version: 0.2.107
Expand Down Expand Up @@ -395,6 +395,59 @@ class Service:
ForbiddenException: When the caller is not an admin of the requested org.
"""
pass

def application_run_update_custom_metadata(
self,
run_id: str,
custom_metadata: dict[str, Any],
*,
custom_metadata_checksum: str | None = None,
enrich_sdk_metadata: bool = True,
) -> None:
"""Update the custom metadata of an existing run.

Args:
run_id: Application run identifier.
custom_metadata: New custom metadata to attach to the run.
custom_metadata_checksum: Optional checksum for optimistic concurrency
control. When provided, a stale write is rejected by the platform
with HTTP 412 and surfaced as ConcurrencyConflictError. None skips
the precondition check.
enrich_sdk_metadata: When True (default), auto-generated SDK tracking
context is merged into the `sdk` field and schema-validated. When
False, custom_metadata is forwarded verbatim (sdk field neither
merged nor validated).

Raises:
NotFoundException: When the run ID is not found.
ConcurrencyConflictError: When the checksum precondition fails (HTTP 412).
ValueError: When the run ID or metadata is invalid.
RuntimeError: When the update fails unexpectedly.
"""
pass

def application_run_update_item_custom_metadata(
self,
run_id: str,
external_id: str,
custom_metadata: dict[str, Any],
*,
custom_metadata_checksum: str | None = None,
enrich_sdk_metadata: bool = True,
) -> None:
"""Update the custom metadata of an item within a run.

Same `custom_metadata_checksum` and `enrich_sdk_metadata` semantics as
`application_run_update_custom_metadata`, scoped to the item identified by
`external_id`.

Raises:
NotFoundException: When the run or item is not found.
ConcurrencyConflictError: When the checksum precondition fails (HTTP 412).
ValueError: When the run ID or item external ID is invalid.
RuntimeError: When the update fails unexpectedly.
"""
pass
```

### 4.2 CLI Interface
Expand Down Expand Up @@ -422,6 +475,12 @@ uvx aignostics application [subcommand] [options]
- `run cancel`: Cancel running application
- `run result download`: Download run results
- `run result delete`: Delete run results
- `run dump-metadata` / `run dump-item-metadata`: Dump a run's/item's custom metadata as JSON;
`--show-checksum` additionally emits the current `custom_metadata_checksum`
- `run update-metadata` / `run update-item-metadata`: Replace a run's/item's custom metadata.
`--checksum` guards the write with optimistic concurrency control (exit code 3 on conflict);
`--enrich-sdk-metadata / --no-enrich-sdk-metadata` (default enrich) controls whether the SDK
merges auto-generated tracking context into the `sdk` field or forwards it verbatim

### 4.3 GUI Interface

Expand Down Expand Up @@ -500,6 +559,7 @@ Configuration is managed through environment variables with the prefix `AIGNOSTI
| `FileNotFoundError` | Missing input files | File validation before upload | File path verification help |
| `ApiException` | Platform API failures | Retry mechanism with recovery | API error details and guidance |
| `ForbiddenException` | Caller not authorized for the requested org | Caught in CLI; exit 2 with access-denied message | User informed they lack permission |
| `ConcurrencyConflictError` | Custom-metadata update rejected (HTTP 412): metadata modified since the checksum was read | `ValueError` subclass; caught in CLI, exit 3 | User told to re-read and retry the update |

### 7.2 Input Validation

Expand Down
41 changes: 40 additions & 1 deletion specifications/SPEC_PLATFORM_SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
itemId: SPEC-PLATFORM-SERVICE
itemTitle: Platform Module Specification
itemType: Software Item Spec
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-1, SWR-APPLICATION-2-5, SWR-APPLICATION-2-6, SWR-APPLICATION-2-7, SWR-APPLICATION-2-9, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-3-1, SWR-APPLICATION-3-2, SWR-APPLICATION-3-3, SWR-APPLICATION-4-1, SWR-APPLICATION-4-2
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-1, SWR-APPLICATION-2-5, SWR-APPLICATION-2-6, SWR-APPLICATION-2-7, SWR-APPLICATION-2-9, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-17, SWR-APPLICATION-3-1, SWR-APPLICATION-3-2, SWR-APPLICATION-3-3, SWR-APPLICATION-4-1, SWR-APPLICATION-4-2
Module: Platform
Layer: Platform Service
Version: 1.2.0
Expand Down Expand Up @@ -544,6 +544,45 @@ class ApplicationRun(_AuthenticatedResource):
) -> None:
"""Ensures all AVAILABLE artifacts for an item are downloaded with checksum verification."""

def update_custom_metadata(
self,
custom_metadata: dict[str, Any],
*,
custom_metadata_checksum: str | None = None,
enrich_sdk_metadata: bool = True,
) -> None:
"""Replace the run's custom metadata.

Args:
custom_metadata: New custom metadata to attach to the run.
custom_metadata_checksum: Optional checksum for optimistic concurrency
control (read from ``ApplicationRunData.custom_metadata_checksum``).
When provided, the platform rejects the write with HTTP 412 if the
stored metadata was modified since the checksum was read. ``None``
skips the precondition check.
enrich_sdk_metadata: When True (default), auto-generated SDK tracking
context is merged into ``custom_metadata["sdk"]`` and validated
against the SDK metadata schema. When False, ``custom_metadata`` is
forwarded verbatim — the ``sdk`` field is neither merged nor
validated — so a previously read ``sdk`` field round-trips unchanged.
"""

def update_item_custom_metadata(
self,
external_id: str,
custom_metadata: dict[str, Any],
*,
custom_metadata_checksum: str | None = None,
enrich_sdk_metadata: bool = True,
) -> None:
"""Replace an item's custom metadata.

Same ``custom_metadata_checksum`` (checksum read from
``ItemResultReadResponse.custom_metadata_checksum``) and
``enrich_sdk_metadata`` semantics as ``update_custom_metadata``, scoped to
the item identified by ``external_id``.
"""

def grant_access(
self,
subject_type: SubjectType,
Expand Down
32 changes: 32 additions & 0 deletions src/aignostics/application/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,38 @@ Every run submitted through the application module automatically includes SDK me

See `platform/CLAUDE.md` for detailed SDK metadata documentation and schema.

**Custom Metadata Updates: Checksum Concurrency Control & `enrich_sdk_metadata` Toggle (NEW):**

`Service.application_run_update_custom_metadata()` / `..._update_item_custom_metadata()` (and their
`_static` wrappers) accept two keyword-only parameters, threaded through to the platform layer:

- `custom_metadata_checksum: str | None` — pass the checksum from a prior read
(`RunData.custom_metadata_checksum` / `ItemResultReadResponse.custom_metadata_checksum`) for
optimistic concurrency control. If the metadata changed since the checksum was read, the platform
returns HTTP 412 and the service raises `aignostics.platform.ConcurrencyConflictError` (a
`ValueError` subclass) instead of silently overwriting a concurrent change.
- `enrich_sdk_metadata: bool = True` — set to `False` to forward `custom_metadata` (including any
`sdk` field) to the platform verbatim, skipping the automatic SDK metadata merge/validation. Use
this when round-tripping a previously dumped `sdk` field unchanged.

CLI support (`_cli.py`):

```bash
# Read-modify-write loop, safe under concurrent edits:
aignostics application run custom-metadata dump-metadata RUN_ID --show-checksum --pretty
# ... edit the dumped JSON ...
aignostics application run custom-metadata update-metadata RUN_ID "<edited json>" \
--checksum <checksum-from-dump> --no-enrich-sdk-metadata
```

- `dump-metadata` / `dump-item-metadata` gained `--show-checksum`, wrapping the output as
`{"custom_metadata": ..., "custom_metadata_checksum": ...}`.
- `update-metadata` / `update-item-metadata` gained `--checksum` and
`--enrich-sdk-metadata`/`--no-enrich-sdk-metadata`. A `ConcurrencyConflictError` from the service
exits the CLI with code 3 (distinct from the existing code 2 for not-found/invalid-ID errors).

See `platform/CLAUDE.md` for the full checksum + `enrich_sdk_metadata` semantics.

**Signed URL Generation:**

The `_download.py` module uses `platform.generate_signed_url()` to convert `gs://` URLs to time-limited signed URLs for downloads.
Expand Down
114 changes: 107 additions & 7 deletions src/aignostics/application/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any

import typer
from loguru import logger
Expand Down Expand Up @@ -1008,19 +1008,35 @@ def run_describe(
def run_dump_metadata(
run_id: Annotated[str, typer.Argument(help="Id of the run to dump custom metadata for")],
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
show_checksum: Annotated[
bool,
typer.Option(
"--show-checksum",
help=(
"Wrap the output as {'custom_metadata': ..., 'custom_metadata_checksum': ...} to obtain the "
"checksum required for optimistic concurrency control on a subsequent 'update-metadata --checksum'."
),
),
] = False,
) -> None:
"""Dump custom metadata of a run as JSON to stdout."""
logger.trace("Dumping custom metadata for run with ID '{}'", run_id)

try:
run = Service().application_run(run_id).details()
custom_metadata = run.custom_metadata if hasattr(run, "custom_metadata") else {}
output: dict[str, Any] | Any = custom_metadata
if show_checksum:
output = {
"custom_metadata": custom_metadata,
"custom_metadata_checksum": getattr(run, "custom_metadata_checksum", None),
}

# Output JSON to stdout
if pretty:
print(json.dumps(custom_metadata, indent=2))
print(json.dumps(output, indent=2))
else:
print(json.dumps(custom_metadata))
print(json.dumps(output))

logger.debug("Dumped custom metadata for run with ID '{}'", run_id)
except NotFoundException:
Expand All @@ -1038,6 +1054,17 @@ def run_dump_item_metadata(
run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")],
external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")],
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
show_checksum: Annotated[
bool,
typer.Option(
"--show-checksum",
help=(
"Wrap the output as {'custom_metadata': ..., 'custom_metadata_checksum': ...} to obtain the "
"checksum required for optimistic concurrency control on a subsequent "
"'update-item-metadata --checksum'."
),
),
] = False,
) -> None:
"""Dump custom metadata of an item as JSON to stdout."""
logger.trace("Dumping custom metadata for item '{}' in run with ID '{}'", external_id, run_id)
Expand All @@ -1061,12 +1088,18 @@ def run_dump_item_metadata(
sys.exit(2)

custom_metadata = item.custom_metadata if hasattr(item, "custom_metadata") else {}
output: dict[str, Any] | Any = custom_metadata
if show_checksum:
output = {
"custom_metadata": custom_metadata,
"custom_metadata_checksum": getattr(item, "custom_metadata_checksum", None),
}

# Output JSON to stdout
if pretty:
print(json.dumps(custom_metadata, indent=2))
print(json.dumps(output, indent=2))
else:
print(json.dumps(custom_metadata))
print(json.dumps(output))

logger.debug("Dumped custom metadata for item '{}' in run with ID '{}'", external_id, run_id)
except NotFoundException:
Expand Down Expand Up @@ -1223,10 +1256,32 @@ def run_update_metadata(
metadata_json: Annotated[
str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')')
],
checksum: Annotated[
str | None,
typer.Option(
"--checksum",
help=(
"Optional custom metadata checksum obtained from 'dump-metadata --show-checksum', used for "
"optimistic concurrency control. If the run's metadata was modified since the checksum was "
"read, the update is rejected (exit code 3)."
),
),
] = None,
enrich_sdk_metadata: Annotated[
bool,
typer.Option(
help=(
"Enrich the sdk field with auto-generated tracking context (submission, user, CI). "
"Use --no-enrich-sdk-metadata to forward the supplied sdk field unchanged."
),
),
] = True,
) -> None:
"""Update custom metadata for a run."""
import json # noqa: PLC0415

from aignostics.platform import ConcurrencyConflictError # noqa: PLC0415

logger.trace("Updating custom metadata for run with ID '{}'", run_id)

try:
Expand All @@ -1240,9 +1295,18 @@ def run_update_metadata(
console.print(f"[error]Error:[/error] Invalid JSON: {e}")
sys.exit(1)

Service().application_run_update_custom_metadata(run_id, custom_metadata)
Service().application_run_update_custom_metadata(
run_id,
custom_metadata,
custom_metadata_checksum=checksum,
enrich_sdk_metadata=enrich_sdk_metadata,
)
logger.debug("Updated custom metadata for run with ID '{}'.", run_id)
console.print(f"Successfully updated custom metadata for run with ID '{run_id}'.")
except ConcurrencyConflictError:
logger.warning(f"Custom metadata for run with ID '{run_id}' was modified by another process.")
console.print("[warning]Warning:[/warning] Metadata was modified by another process. Re-read and retry.")
sys.exit(3)
except NotFoundException:
logger.warning(f"Run with ID '{run_id}' not found.")
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
Expand All @@ -1264,10 +1328,32 @@ def run_update_item_metadata(
metadata_json: Annotated[
str, typer.Argument(..., help='Custom metadata as JSON string (e.g., \'{"key": "value"}\')')
],
checksum: Annotated[
str | None,
typer.Option(
"--checksum",
help=(
"Optional custom metadata checksum obtained from 'dump-item-metadata --show-checksum', used "
"for optimistic concurrency control. If the item's metadata was modified since the checksum "
"was read, the update is rejected (exit code 3)."
),
),
] = None,
enrich_sdk_metadata: Annotated[
bool,
typer.Option(
help=(
"Enrich the sdk field with auto-generated tracking context (submission, user, CI). "
"Use --no-enrich-sdk-metadata to forward the supplied sdk field unchanged."
),
),
] = True,
) -> None:
"""Update custom metadata for an item in a run."""
import json # noqa: PLC0415

from aignostics.platform import ConcurrencyConflictError # noqa: PLC0415

logger.trace("Updating custom metadata for item '{}' in run with ID '{}'", external_id, run_id)

try:
Expand All @@ -1281,9 +1367,23 @@ def run_update_item_metadata(
console.print(f"[error]Error:[/error] Invalid JSON: {e}")
sys.exit(1)

Service().application_run_update_item_custom_metadata(run_id, external_id, custom_metadata)
Service().application_run_update_item_custom_metadata(
run_id,
external_id,
custom_metadata,
custom_metadata_checksum=checksum,
enrich_sdk_metadata=enrich_sdk_metadata,
)
logger.debug("Updated custom metadata for item '{}' in run with ID '{}'.", external_id, run_id)
console.print(f"Successfully updated custom metadata for item '{external_id}' in run with ID '{run_id}'.")
except ConcurrencyConflictError:
logger.warning(
"Custom metadata for item '{}' in run with ID '{}' was modified by another process.",
external_id,
run_id,
)
console.print("[warning]Warning:[/warning] Metadata was modified by another process. Re-read and retry.")
sys.exit(3)
except NotFoundException:
logger.warning(f"Run with ID '{run_id}' or item '{external_id}' not found.")
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' or item '{external_id}' not found.")
Expand Down
Loading
Loading