Skip to content

[caliper] Introduce the artifact-exporter - #37

Merged
kpouget merged 16 commits into
openshift-psap:mainfrom
kpouget:009-artifact-post-processing
Apr 27, 2026
Merged

kpouget merged 16 commits into
openshift-psap:mainfrom
kpouget:009-artifact-post-processing

Conversation

@kpouget

@kpouget kpouget commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • New caliper CLI (caliper) and executable wrapper with an artifacts export command supporting S3 and MLflow, dry-run, verbose output, and status YAML.
    • Multi-backend export engine with concurrent uploads, MLflow model & metadata logging, redaction/filtering, and per-backend success/failure reporting.
  • Documentation

    • Comprehensive specs, contracts, quickstart, checklists, example MLflow config, and README for artifact post-processing and Caliper usage.
  • Chores

    • CI/vault/config updates to enable orchestration-triggered Caliper exports.

@openshift-ci

openshift-ci Bot commented Apr 21, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign mml-coder for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai

coderabbitai Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@kpouget has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 54 minutes and 29 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cfae5c62-e547-48d7-9c25-32800155ce38

📥 Commits

Reviewing files that changed from the base of the PR and between 32791f7 and 2299b0e.

📒 Files selected for processing (41)
  • bin/caliper
  • docs/caliper/mlflow-config.example.yaml
  • projects/caliper/README.md
  • projects/caliper/__init__.py
  • projects/caliper/cli/__init__.py
  • projects/caliper/cli/main.py
  • projects/caliper/engine/__init__.py
  • projects/caliper/engine/file_export/__init__.py
  • projects/caliper/engine/file_export/artifacts_export_run.py
  • projects/caliper/engine/file_export/mlflow_backend.py
  • projects/caliper/engine/file_export/mlflow_config.py
  • projects/caliper/engine/file_export/mlflow_secrets.py
  • projects/caliper/engine/file_export/runner.py
  • projects/caliper/engine/file_export/types.py
  • projects/caliper/engine/model.py
  • projects/caliper/orchestration/__init__.py
  • projects/caliper/orchestration/export.py
  • projects/caliper/orchestration/export_config.py
  • projects/core/ci_entrypoint/fournos.py
  • projects/core/ci_entrypoint/run_ci.py
  • projects/core/library/ci.py
  • projects/core/library/export.py
  • projects/core/notifications/send.py
  • projects/fournos_launcher/toolbox/submit_and_wait/main.py
  • projects/jump_ci/testing/run
  • projects/skeleton/orchestration/ci.py
  • projects/skeleton/orchestration/config.yaml
  • pyproject.toml
  • specs/009-artifact-post-processing/checklists/requirements.md
  • specs/009-artifact-post-processing/checklists/spec-quality.md
  • specs/009-artifact-post-processing/contracts/cli.md
  • specs/009-artifact-post-processing/contracts/kpi_record.md
  • specs/009-artifact-post-processing/contracts/postprocess_manifest.md
  • specs/009-artifact-post-processing/contracts/test_labels.md
  • specs/009-artifact-post-processing/data-model.md
  • specs/009-artifact-post-processing/plan.md
  • specs/009-artifact-post-processing/quickstart.md
  • specs/009-artifact-post-processing/research.md
  • specs/009-artifact-post-processing/spec.md
  • specs/009-artifact-post-processing/tasks.md
  • vaults/psap-forge-mlflow-export.yaml
📝 Walkthrough

Walkthrough

Adds a new Caliper artifact-export feature: CLI entrypoint and Bash wrapper, multi-backend file export engine (S3 and MLflow) with config/secrets validation, orchestration Pydantic schemas and runtime binding, CI integration hooks, S3/MLflow backends, status YAML output, and extensive spec/docs.

Changes

Cohort / File(s) Summary
CLI Entrypoints
bin/caliper, pyproject.toml, projects/caliper/cli/main.py
Adds a bash wrapper and Click-based caliper CLI with artifacts export, run_cli entrypoint, console-script registration, and argument/config loading/validation.
Package Init & README
projects/caliper/__init__.py, projects/caliper/cli/__init__.py, projects/caliper/README.md, projects/caliper/engine/__init__.py, projects/caliper/engine/file_export/__init__.py
Adds package scaffolding, module docstrings, README, and explicit exports.
Core Models & Types
projects/caliper/engine/model.py, projects/caliper/engine/file_export/types.py
Introduces FileExportManifest and FileExportBackendResult dataclasses and re-exports types for the file-export pipeline.
Orchestration API & Config
projects/caliper/orchestration/__init__.py, projects/caliper/orchestration/export.py, projects/caliper/orchestration/export_config.py
Adds Pydantic orchestration schemas, vault-based secret resolution, and run_from_orchestration_config orchestration entrypoint.
Export Runner & Orchestration Runtime
projects/caliper/engine/file_export/runner.py, projects/caliper/engine/file_export/artifacts_export_run.py
Implements file discovery, redaction policy, backend orchestration, MLflow config/secret merging/validation, status YAML writing, verbose logging, exception-to-exit-code mapping, and exit-code semantics.
MLflow Integration
projects/caliper/engine/file_export/mlflow_backend.py, projects/caliper/engine/file_export/mlflow_config.py, projects/caliper/engine/file_export/mlflow_secrets.py, docs/caliper/mlflow-config.example.yaml
Implements MLflow artifact logging, run metadata/params/metrics handling, model logging (sklearn), YAML config/secrets loading and validation, env-context manager for credentials, and example config.
S3 Backend
projects/caliper/engine/file_export/s3.py
Adds S3 upload implementation with boto3 dynamic import, per-file key resolution, optional parallel uploads and worker capping, returns summary string.
Runner Types & Exports
projects/caliper/engine/file_export/types.py
Adds typed re-exports and __all__ for file-export public API.
Orchestration CI Integration
projects/core/library/export.py, projects/skeleton/orchestration/ci.py, projects/skeleton/orchestration/config.yaml
Adds shared caliper export command, integrates orchestration export into skeleton CI end-of-test hook, and provides example orchestration config (dry-run).
CI Entrypoint & Tooling Edits
projects/core/ci_entrypoint/run_ci.py, projects/core/library/ci.py, projects/core/notifications/send.py, projects/jump_ci/testing/run, projects/core/ci_entrypoint/fournos.py
Adds pydantic to CI extra packages, embeds exception class/message in error summaries, safer JOB_SPEC parsing debug output, logging/import wiring tweaks, and unescaping of shell-quoted env values.
Vault & Secrets
vaults/psap-forge-mlflow-export.yaml
Adds vault template for MLflow and AWS secrets expected by orchestration.
Specs & Documentation
specs/009-artifact-post-processing/...
Adds comprehensive spec, contracts, data model, plan, tasks, quickstart, research, checklists, and CLI contract docs for the artifact post-processing feature.
Fournos Launcher Tasks
projects/fournos_launcher/toolbox/submit_and_wait/main.py
Adds always-run tasks to capture PipelineRun and Pod specs into artifacts directory.
Misc docs/example
docs/caliper/mlflow-config.example.yaml, projects/caliper/README.md
Provides MLflow example YAML and Caliper README.

Sequence Diagram(s)

sequenceDiagram
    participant User as "User"
    participant CLI as "caliper CLI\n(projects.caliper.cli.main)"
    participant Orch as "Orchestrator\n(artifacts_export_run.py)"
    participant Runner as "Runner\n(runner.py)"
    participant S3 as "S3 Backend\n(s3.py)"
    participant MLflow as "MLflow Backend\n(mlflow_backend.py)"

    User->>CLI: caliper artifacts export --from <path> --backend s3 mlflow ...
    CLI->>Orch: run_artifacts_export(...)
    Orch->>Orch: merge configs & secrets\nvalidate MLflow inputs
    Orch->>Runner: run_file_export(source, backends, ...)
    Runner->>Runner: discover files\napply redaction
    Runner->>S3: upload_tree(paths, bucket, prefix, ...)
    S3-->>Runner: s3 result (status, detail)
    Runner->>MLflow: log_artifacts(artifact_root, paths, tracking_uri, ...)
    MLflow-->>Runner: mlflow result (status, metadata)
    Runner-->>Orch: collected backend results
    Orch->>Orch: write status YAML (optional)\ncompute exit code
    Orch-->>CLI: exit code
    CLI-->>User: exit code + printed status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through tests and YAML rows,

Packed secrets safe where MLflow goes.
I stitched S3 keys and status files,
Sent CI carrots across the miles.
Hooray — exports bound for skies and piles!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '[caliper] Introduce the artifact-exporter' directly summarizes the main addition: a new Caliper artifact export feature with CLI, configuration, backends (S3/MLflow), and orchestration support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kpouget
kpouget force-pushed the 009-artifact-post-processing branch 2 times, most recently from 309ba78 to d6f956c Compare April 23, 2026 14:00
@kpouget

kpouget commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton long_test
/cluster psap-mgmt

@coderabbitai coderabbitai 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.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyproject.toml (1)

105-109: ⚠️ Potential issue | 🟠 Major

Include Caliper tests in default pytest discovery.

testpaths still only collects projects/core/tests, so the new projects/caliper/tests suite/fixtures can be skipped by a plain pytest run.

🧪 Proposed fix
-testpaths = ["projects/core/tests"]
+testpaths = ["projects/core/tests", "projects/caliper/tests"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` around lines 105 - 109, The pytest config's test discovery
only lists "projects/core/tests" under tool.pytest.ini_options.testpaths so
Caliper tests are skipped; update the testpaths value (the testpaths setting in
pyproject.toml) to include the Caliper test directory (e.g., add
"projects/caliper/tests" or use a glob like "projects/*/tests") so pytest will
discover both core and caliper test suites by default.
🟡 Minor comments (18)
projects/core/library/ci.py-52-53 (1)

52-53: ⚠️ Potential issue | 🟡 Minor

Normalize multiline exception messages before adding them to the summary.

Line 53 can inject raw newlines into summary_lines; if str(e) contains a standalone ---, it can trigger the notification cutoff described on Line 49 and hide the traceback/details.

Proposed fix
     # Add the full stacktrace
     summary_lines.append(f"--- 📍{e.__class__.__name__} STACKTRACE ---")
-    summary_lines.append(f"--- 📍{str(e)}")
+    summary_lines.append("--- 📍Exception message:")
+    exception_message_lines = str(e).splitlines() or ["<no message>"]
+    for message_line in exception_message_lines:
+        # Avoid accidentally emitting the notification detail delimiter.
+        if message_line.strip() == "---":
+            message_line = "\\---"
+        summary_lines.append(f"   {message_line}")
     summary_lines.append("")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/core/library/ci.py` around lines 52 - 53, Normalize and sanitize
exception text before appending to summary_lines: take str(e), collapse or
escape newlines (e.g., replace actual newlines with '\n' or join lines with a
separator) and sanitize any standalone '---' sequences (replace or escape them)
so the message cannot inject separators or break the notification cutoff; then
append the sanitized one-line message to summary_lines (references:
summary_lines and exception variable e in projects/core/library/ci.py).
projects/caliper/engine/label_filters.py-10-17 (1)

10-17: ⚠️ Potential issue | 🟡 Minor

Reject empty filter keys.

Line 16 accepts inputs like =team-a or =team-a, which silently creates an empty-key filter and can make the CLI return confusing results.

Proposed fix
 def parse_filter_kv(pairs: tuple[str, ...]) -> dict[str, str]:
     out: dict[str, str] = {}
     for p in pairs:
         if "=" not in p:
             raise ValueError(f"Invalid filter (expected KEY=VALUE): {p}")
-        k, v = p.split("=", 1)
-        out[k.strip()] = v.strip()
+        raw_k, raw_v = p.split("=", 1)
+        k = raw_k.strip()
+        if not k:
+            raise ValueError(f"Invalid filter (expected non-empty KEY=VALUE): {p!r}")
+        out[k] = raw_v.strip()
     return out
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/label_filters.py` around lines 10 - 17, The function
parse_filter_kv currently allows empty keys (e.g., "=team-a") which produces a
silent empty-key entry; update parse_filter_kv to validate the key after
splitting by "=" by computing key = k.strip() and if not key raise a ValueError
(e.g., "Invalid filter (empty key): {p}") instead of inserting it into out, then
use that stripped key to set out[key] = v.strip(); keep the existing handling
for missing "=" and unchanged behavior for values.
projects/caliper/schemas/ai_eval_payload.schema.json-4-12 (1)

4-12: ⚠️ Potential issue | 🟡 Minor

Tighten validation for metrics and explicit-null optional fields.

As written, a payload without metrics passes validation, and optional can contain non-null values despite the “Explicit nulls” contract.

🧾 Proposed schema fix
-  "required": ["schema_version", "run_id"],
+  "required": ["schema_version", "run_id", "metrics"],
   "properties": {
     "schema_version": { "type": "string" },
     "run_id": { "type": "string" },
     "metrics": { "type": "object" },
     "optional": {
       "description": "Explicit nulls for missing optional agent metrics",
-      "type": "object"
+      "type": "object",
+      "additionalProperties": { "type": "null" }
     }
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/schemas/ai_eval_payload.schema.json` around lines 4 - 12,
Make "metrics" a required property by adding "metrics" to the top-level
"required" array so a payload without metrics fails validation, and tighten the
"optional" property by changing its schema (the "optional" entry under
"properties") to enforce an object whose property values must be explicit nulls
(set "type":"object" and use "additionalProperties":{"type":"null"} or
equivalent) so any keys under "optional" cannot hold non-null values; keep the
descriptive "description" but ensure the schema prohibits non-null values.
projects/caliper/engine/load_plugin.py-20-26 (1)

20-26: ⚠️ Potential issue | 🟡 Minor

Check ModuleNotFoundError.name to distinguish missing transitive dependencies from missing plugin modules.

When a plugin module imports a missing dependency, Python raises ModuleNotFoundError with e.name pointing to the missing dependency, not the plugin module itself. The current code always reports this as a plugin import failure, incorrectly telling users to check PYTHONPATH and package installation. Distinguish the two cases using e.name to provide accurate error messages:

Proposed import-error classification
     try:
         mod = importlib.import_module(module_path)
     except ModuleNotFoundError as e:
+        missing_module = e.name or ""
+        missing_requested_module = (
+            missing_module == module_path
+            or module_path.startswith(f"{missing_module}.")
+        )
+        if not missing_requested_module:
+            raise RuntimeError(
+                f"Failed loading plugin module {module_path!r}: "
+                f"missing dependency {missing_module!r}."
+            ) from e
         raise RuntimeError(
             f"Cannot import plugin module {module_path!r}: {e}. "
             "Check PYTHONPATH and package installation."
         ) from e
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/load_plugin.py` around lines 20 - 26, The
ModuleNotFoundError handler should check e.name to distinguish a missing plugin
package from a missing transitive dependency: in the except ModuleNotFoundError
as e block, compare e.name to the plugin's module path root (e.g., module_path
or module_path.split(".")[0]); if they match, keep the existing RuntimeError
about failing to import the plugin module and checking PYTHONPATH/installation,
otherwise raise a RuntimeError that clearly states a missing dependency (include
e.name) occurred while importing the plugin module (and suggest installing that
dependency); preserve the original exception chaining (from e).
projects/caliper/engine/load_plugin.py-29-40 (1)

29-40: ⚠️ Potential issue | 🟡 Minor

Add callability guard and runtime type validation for loaded plugins.

hasattr(mod, "get_plugin") does not guarantee it is callable—the current code will fail if get_plugin exists as a non-callable attribute. Additionally, cast(PostProcessingPlugin, plugin) provides no runtime validation; malformed plugins will pass this function and fail later outside the loader's error handling.

Since PostProcessingPlugin is an ABC (not a Protocol), isinstance(plugin, PostProcessingPlugin) will validate subclasses but not duck-typed implementations. For robustness, explicitly check that the loaded plugin has the required parse method and is callable:

Recommended validation approach
    plugin: Any = None
    if hasattr(mod, "get_plugin"):
-       plugin = mod.get_plugin()
+       get_plugin = getattr(mod, "get_plugin", None)
+       if not callable(get_plugin):
+           raise RuntimeError(
+               f"Plugin module {module_path!r} defines get_plugin, "
+               "but it is not callable."
+           )
+       plugin = get_plugin()
    elif hasattr(mod, "plugin"):
        plugin = mod.plugin
    else:
        raise RuntimeError(
            f"Plugin module {module_path!r} must define get_plugin() or 'plugin' "
            "returning a PostProcessingPlugin implementation."
        )
    if plugin is None:
        raise RuntimeError(f"Plugin module {module_path!r} returned no plugin.")
-   return cast(PostProcessingPlugin, plugin)
+   if not hasattr(plugin, "parse") or not callable(getattr(plugin, "parse")):
+       raise RuntimeError(
+           f"Plugin module {module_path!r} returned an object missing the "
+           "required parse() method."
+       )
+   return plugin
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/load_plugin.py` around lines 29 - 40, The loader
currently assumes mod.get_plugin is callable and that the returned object is a
valid PostProcessingPlugin; update the branch that checks hasattr(mod,
"get_plugin") to verify callable(get_plugin) before invoking it (raise a
RuntimeError if it's present but not callable), and after obtaining plugin (from
get_plugin() or mod.plugin) perform runtime validation: if isinstance(plugin,
PostProcessingPlugin) accept it, otherwise require that plugin has a callable
parse method (e.g., hasattr(plugin, "parse") and callable(plugin.parse)); if
validation fails raise a RuntimeError describing the missing/invalid entry so
malformed plugins fail fast and clearly (refer to get_plugin, plugin,
PostProcessingPlugin, and parse in the changes).
specs/009-artifact-post-processing/contracts/postprocess_manifest.md-10-14 (1)

10-14: ⚠️ Potential issue | 🟡 Minor

Make manifest discovery names normative here.

This contract leaves the conventional filename list “implementation-defined,” so users cannot tell whether caliper.yaml, forge-postprocess.yaml, and postprocess.yaml are all guaranteed. Please make this list authoritative here and have the README/quickstart reference it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/009-artifact-post-processing/contracts/postprocess_manifest.md` around
lines 10 - 14, The discovery section for postprocess manifests currently leaves
the conventional filename list implementation-defined; update the contract so
that the manifest discovery logic (triggered by the --postprocess-config PATH
flag and the fallback under --base-dir) uses a normative, authoritative ordered
list of filenames — explicitly include "caliper.yaml", "forge-postprocess.yaml",
and "postprocess.yaml" (and any other chosen names) and state the exact search
order and precedence; then update the projects/caliper/README.md/quickstart to
reference this authoritative list so users know which filenames are guaranteed
to be recognized.
projects/caliper/engine/kpi/opensearch_client.py-21-27 (1)

21-27: ⚠️ Potential issue | 🟡 Minor

Validate OPENSEARCH_HOSTS entries before building the client.

A trailing comma, empty host, or non-numeric port currently creates an invalid host entry or raises a raw ValueError, which makes operator misconfiguration harder to diagnose.

Suggested validation
-    for h in hosts_env.split(","):
-        h = h.strip()
+    for raw_host in hosts_env.split(","):
+        h = raw_host.strip()
+        if not h:
+            continue
         if ":" in h:
             host, port_s = h.rsplit(":", 1)
-            host_list.append({"host": host, "port": int(port_s)})
+            if not host:
+                raise RuntimeError("OPENSEARCH_HOSTS contains an empty host")
+            try:
+                port = int(port_s)
+            except ValueError as e:
+                raise RuntimeError(f"Invalid OpenSearch port in OPENSEARCH_HOSTS entry: {h}") from e
+            host_list.append({"host": host, "port": port})
         else:
             host_list.append({"host": h, "port": 9200})
+
+    if not host_list:
+        raise RuntimeError("OPENSEARCH_HOSTS must contain at least one host")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/kpi/opensearch_client.py` around lines 21 - 27,
Validate and sanitize OPENSEARCH_HOSTS entries before appending to host_list:
after splitting and stripping each hosts_env entry (the variable hosts_env and
the list host_list in this loop), skip empty strings (handle trailing commas),
and when ":" exists ensure the port substring is numeric and within 1–65535
(instead of blindly int(port_s))—if not, raise a clear ValueError that includes
the offending host token; for entries without ":", append the host with default
port 9200 as before. This ensures bad entries produce a helpful error rather
than a raw exception.
projects/caliper/engine/ai_eval.py-31-33 (1)

31-33: ⚠️ Potential issue | 🟡 Minor

Create the output directory before writing the AI eval payload.

Nested --output paths will fail with FileNotFoundError even after the parse and validation work succeeds.

Suggested fix
     import json  # noqa: PLC0415
 
+    output.parent.mkdir(parents=True, exist_ok=True)
     output.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
     return payload
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/ai_eval.py` around lines 31 - 33, The code calls
output.write_text(...) without ensuring the directory exists, causing
FileNotFoundError for nested --output paths; before calling
output.write_text(json.dumps(payload,...)) create the parent directories by
invoking output.parent.mkdir(parents=True, exist_ok=True) (or equivalent) so the
directory is created if missing; locate the write call (output.write_text) and
add the mkdir step just above it, referencing the same variables output and
payload.
specs/009-artifact-post-processing/contracts/test_labels.md-14-32 (1)

14-32: ⚠️ Potential issue | 🟡 Minor

Make the required YAML shape explicit.

As written, a present __test_labels__.yaml only MUST be valid YAML; labels is merely SHOULD. That allows files that mark a test base but provide no usable label map, which undermines filtering/KPI attribution.

Suggested wording
-Recommended shape (flexible superset; engine normalizes to string map where possible):
+Required minimal shape:
@@
-- `labels` SHOULD be a flat map of string → string for filtering and KPI dimensions; nested structures are plugin-defined and may be flattened by the plugin.
+- `labels` MUST be present and MUST be a mapping.
+- Label keys MUST be strings. Label values SHOULD be strings for filtering and KPI dimensions; non-string values, if accepted, MUST be normalized consistently before use.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/009-artifact-post-processing/contracts/test_labels.md` around lines 14
- 32, The spec currently only requires that a present __test_labels__.yaml be
valid YAML and marks the labels key as "SHOULD", which allows files without a
usable labels map; update the contract text in
specs/009-artifact-post-processing/contracts/test_labels.md to make the required
YAML shape explicit by changing the rule so that when __test_labels__.yaml
exists it MUST contain a top-level "labels" key that is a flat map of
string→string (i.e., enforce "labels" MUST be a mapping of strings to strings)
and clarify that plugins may flatten nested structures but the file itself must
provide a usable label map; reference the example YAML and the "labels" key in
the Rules section so consumers and validators enforce this constraint.
specs/009-artifact-post-processing/quickstart.md-54-58 (1)

54-58: ⚠️ Potential issue | 🟡 Minor

Remove the "(default TBD)" from the quickstart, or clarify that --output-dir is required.

The --output-dir parameter for the visualize command is required with no default value in the CLI definition. The quickstart should not list a pending default; instead, explicitly state that users must provide this parameter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/009-artifact-post-processing/quickstart.md` around lines 54 - 58,
Update the quickstart text to remove the misleading "(default TBD)" and clearly
state that the visualize command requires the --output-dir parameter;
specifically, edit the section listing "HTML reports" so it reads that HTML
reports are written to the path supplied via --output-dir for the visualize
command (no default), and ensure references to the visualize CLI flag
(--output-dir) and the visualize command are used so readers know the parameter
is mandatory.
specs/009-artifact-post-processing/contracts/cli.md-50-50 (1)

50-50: ⚠️ Potential issue | 🟡 Minor

Fix the wording in the KPI import description.

“for analyze” should be “for analysis”.

Proposed fix
-Download **historical KPIs** from OpenSearch into a local snapshot (for analyze or audit).
+Download **historical KPIs** from OpenSearch into a local snapshot (for analysis or audit).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/009-artifact-post-processing/contracts/cli.md` at line 50, The sentence
"Download **historical KPIs** from OpenSearch into a local snapshot (for analyze
or audit)." uses the incorrect word "analyze"; update the wording to "for
analysis or audit" (e.g., change the parenthetical to "(for analysis or audit)")
in the CLI documentation entry that contains the KPI import description.
specs/009-artifact-post-processing/checklists/requirements.md-37-37 (1)

37-37: ⚠️ Potential issue | 🟡 Minor

Fix the future-dated clarification entry.

Today is April 23, 2026, but this entry is dated April 24, 2026.

Proposed fix
-- Clarification pass (2026-04-24): **Parse cache** file—reload to skip full re-parse; FR-016, **Parse cache** entity, US1 acceptance, edge cases, SC-006, assumptions.
+- Clarification pass (2026-04-23): **Parse cache** file—reload to skip full re-parse; FR-016, **Parse cache** entity, US1 acceptance, edge cases, SC-006, assumptions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/009-artifact-post-processing/checklists/requirements.md` at line 37,
Update the future-dated clarification entry "Clarification pass (2026-04-24):
**Parse cache** file—reload to skip full re-parse; FR-016, **Parse cache**
entity, US1 acceptance, edge cases, SC-006, assumptions." by changing the date
from 2026-04-24 to 2026-04-23 so the clarification entry is not dated in the
future.
projects/caliper/engine/kpi/analyze.py-65-79 (1)

65-79: ⚠️ Potential issue | 🟡 Minor

Create output_path parent before writing.

output_path.write_text(...) will raise FileNotFoundError if the destination directory doesn't exist. Either mkdir(parents=True, exist_ok=True) the parent here, or document that callers must pre-create it.

Proposed fix
+    output_path.parent.mkdir(parents=True, exist_ok=True)
     output_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/kpi/analyze.py` around lines 65 - 79, The write to
output_path can fail if its parent directory doesn't exist; before calling
output_path.write_text(...) in analyze.py, ensure the parent directory is
created (e.g., call output_path.parent.mkdir(parents=True, exist_ok=True)) so
the file can be written reliably; update the block around output_path.write_text
to create the parent directory using output_path.parent.mkdir(...) prior to
writing.
projects/caliper/engine/file_export/runner.py-68-73 (1)

68-73: ⚠️ Potential issue | 🟡 Minor

Unknown backend should report failure, not skipped.

Per FR-017, per-backend outcomes are reported distinctly and a failure must not be collapsed into overall success. "skipped" semantically implies an intentional opt-out (matching the dry_run branch below at lines 80-85). An unrecognized backend name is a configuration error and should be surfaced as "failure" so aggregators / exit-status logic in artifacts_export_run.py treat the run as unsuccessful.

🛠️ Proposed fix
-        if b not in ("s3", "mlflow"):
-            results.append(
-                FileExportBackendResult(backend=b, status="skipped", detail="unknown backend")
-            )
-            continue
+        if b not in ("s3", "mlflow"):
+            results.append(
+                FileExportBackendResult(backend=b, status="failure", detail="unknown backend")
+            )
+            continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/runner.py` around lines 68 - 73, The loop
over backends in runner.py currently appends FileExportBackendResult(...,
status="skipped", detail="unknown backend") for unrecognized backends; change
this to report a failure so aggregators treat it as an error: in the for b in
backends loop replace status="skipped" with status="failure" (keep or clarify
detail="unknown backend") when b is not in ("s3","mlflow"), ensuring
FileExportBackendResult reflects a failure for unknown backends.
projects/caliper/engine/parse.py-37-42 (1)

37-42: ⚠️ Potential issue | 🟡 Minor

Cache-hit path leaves parse_cache_ref unset on the returned model.

When cache is valid, model_from_jsonable(raw["unified_model"]) reconstructs the model but parse_cache_ref is not serialized by model_to_jsonable (see validation.py lines 50-56), so the reloaded model returns with parse_cache_ref=None. The non-cached path sets parse_cache_ref=str(path) (lines 55 and 68). Downstream consumers inspecting this field will see inconsistent values across invocations.

🛠️ Proposed fix
     if use_cache:
         raw = read_cache(path)
         if raw is not None and cache_is_valid(
             raw, expected_fingerprint=fp, plugin_module=plugin_module
         ):
-            return model_from_jsonable(raw["unified_model"])
+            model = model_from_jsonable(raw["unified_model"])
+            model.parse_cache_ref = str(path)
+            return model
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/parse.py` around lines 37 - 42, The cache-hit branch
returns model_from_jsonable(raw["unified_model"]) without restoring the
parse_cache_ref, so models loaded from cache have parse_cache_ref=None whereas
the non-cached path sets parse_cache_ref=str(path); update the cache-hit path in
parse.py (the block using read_cache, cache_is_valid and model_from_jsonable) to
set parse_cache_ref=str(path) on the reconstructed model before returning it
(i.e., assign the attribute on the object returned by model_from_jsonable) so
cached and non-cached code paths produce consistent parse_cache_ref values.
specs/009-artifact-post-processing/research.md-68-68 (1)

68-68: ⚠️ Potential issue | 🟡 Minor

Broken markdown emphasis.

**--reports a,b,c**** list** has four consecutive asterisks and will not render as intended — the **** list** segment produces a stray ** in output. Drop the duplicated markers.

🛠️ Proposed fix
-**Decision**: Support **mutually exclusive** modes: (a) **`--reports a,b,c`**** list** matching plugin report ids, or (b) **`--report-group <id>`** resolved from a **YAML/JSON config file** path (`--visualize-config` or default under base dir). **Label filters**: **`--include-label key=value`** / **`--exclude-label key=value`** (repeatable).
+**Decision**: Support **mutually exclusive** modes: (a) a **`--reports a,b,c`** list matching plugin report ids, or (b) **`--report-group <id>`** resolved from a **YAML/JSON config file** path (`--visualize-config` or default under base dir). **Label filters**: **`--include-label key=value`** / **`--exclude-label key=value`** (repeatable).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/009-artifact-post-processing/research.md` at line 68, Fix the broken
Markdown emphasis in the decision line: locate the phrase containing `--reports
a,b,c` and remove the duplicated asterisks so the bolding renders correctly
(change `**`--reports a,b,c`**** list**` to something like `**--reports a,b,c
list**` or `**--reports a,b,c** list`), ensuring only matching pairs of `**`
surround the intended bold text and leaving the rest of the sentence (including
`--report-group <id>` and the label filter examples `--include-label key=value`
/ `--exclude-label key=value`) unchanged.
projects/caliper/engine/file_export/mlflow_backend.py-190-200 (1)

190-200: ⚠️ Potential issue | 🟡 Minor

_maybe_param and user_param_keys are dead code.

user_param_keys is computed and _maybe_param is defined, but neither is used — the subsequent loop calls mlflow.log_param directly on every (pk, pv) pair. Either remove the unused helper/set, or route parameter logging through _maybe_param so the "avoid duplicate" behavior described in the docstring is actually applied.

-    params = run_metadata.get("parameters")
-    user_param_keys: set[str] = set(params.keys()) if isinstance(params, dict) else set()
-
-    def _maybe_param(key: str, value: str) -> None:
-        """Avoid duplicate ``log_param`` if the YAML ``parameters`` block already set this key."""
-        if key not in user_param_keys:
-            mlflow.log_param(key, value)
-
-    if isinstance(params, dict):
+    params = run_metadata.get("parameters")
+    if isinstance(params, dict):
         for pk, pv in params.items():
             mlflow.log_param(str(pk), "" if pv is None else str(pv))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_backend.py` around lines 190 -
200, The code builds user_param_keys and defines _maybe_param to avoid duplicate
logging but then ignores them; either remove user_param_keys and _maybe_param or
use them when iterating params—update the loop that currently calls
mlflow.log_param(str(pk), ...) to call _maybe_param(str(pk), "" if pv is None
else str(pv)) so the duplicate-avoidance behavior is applied (references:
user_param_keys, _maybe_param, mlflow.log_param, params, run_metadata).
projects/caliper/engine/file_export/mlflow_backend.py-5-19 (1)

5-19: ⚠️ Potential issue | 🟡 Minor

Fix Ruff failures: unused sys import and E402 (imports below statement).

Pipeline is failing: sys is imported but never used, and the from projects.caliper... import sits after the logger = logging.getLogger(__name__) statement, triggering E402.

 import logging
 import subprocess
-import sys
 import threading
 from concurrent.futures import ThreadPoolExecutor, as_completed
 from pathlib import Path
 from typing import Any

-logger = logging.getLogger(__name__)
-
 from projects.caliper.engine.file_export.mlflow_secrets import (
     assert_tracking_uri_has_no_userinfo,
     mlflow_connection_env,
 )
+
+logger = logging.getLogger(__name__)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_backend.py` around lines 5 - 19,
Remove the unused sys import and fix the E402 by moving the project import block
above any executable statements: place the "from
projects.caliper.engine.file_export.mlflow_secrets import
(assert_tracking_uri_has_no_userinfo, mlflow_connection_env)" line up with the
other imports, before the logger = logging.getLogger(__name__) assignment, and
delete the unused "import sys" line so only used imports remain.
🧹 Nitpick comments (6)
projects/caliper/engine/kpi/analyze.py (1)

44-55: Minor: higher_is_better source is brittle.

Reading higher_is_better from labels (arbitrary operator-controlled strings) rather than the KPI catalog couples regression direction to user-supplied metadata. If a user accidentally omits or overrides the label, the default "true" silently decides direction. Consider resolving direction from the plugin's kpi_catalog() entry keyed by kpi_id, with labels only as a fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/kpi/analyze.py` around lines 44 - 55, The current
logic derives higher_is_better from labels which is brittle; change it to first
look up the KPI definition from the plugin's kpi_catalog() by kpi_id and use its
higher_is_better flag (or equivalent) to decide direction, falling back to
labels.get("higher_is_better") only if the catalog entry is missing or has no
flag, and preserve the existing boolean parsing logic; update the block that
computes higher (used by _direction_worse(cv, bv, higher_is_better=higher)) to
fetch from the catalog (via kpi_catalog() and kpi_id) before reading labels so
regression direction is determined by the authoritative KPI catalog.
projects/caliper/engine/file_export/s3.py (1)

31-42: Consider exposing endpoint/region for S3-compatible stores.

boto3.client("s3") with no args relies entirely on ambient config. Since the plan calls out "S3-compatible" targets (MinIO, Ceph, etc.), plumbing through explicit endpoint_url, region_name, and signature-version options (or at minimum documenting the env-var contract) will save operators a debugging round-trip. Also consider catching p.relative_to(root) failures when a caller passes a path outside source.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/s3.py` around lines 31 - 42, The code
currently constructs the S3 client with boto3.client("s3") and assumes paths are
under source; change the S3 client construction to accept/extract explicit
endpoint/region/signature options (e.g., endpoint_url, region_name, and a
botocore Config with signature_version) from function args or environment
variables and pass them into boto3.client to support S3-compatible stores
(MinIO/Ceph); also wrap the rel = fp.relative_to(root) call in a try/except
ValueError inside the loop (or validate root.contains(fp) earlier) and either
fall back to Path(p.name) or raise a clear error so callers passing paths
outside source get a helpful message.
projects/caliper/engine/parse.py (1)

57-59: Emit warnings to stderr, not stdout.

The inline comment labels this as "CLI feedback", yet print(...) defaults to stdout, which can contaminate piped output (e.g., if downstream commands consume stdout). Use file=sys.stderr for consistency with similar diagnostic output in file_export/runner.py.

🛠️ Proposed fix
+import sys
 ...
     if warnings and force_report_partial:
         for w in warnings:
-            print(f"[parse warning] {w}")  # noqa: T201 — CLI feedback
+            print(f"[parse warning] {w}", file=sys.stderr)  # noqa: T201 — CLI feedback
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/parse.py` around lines 57 - 59, The current loop
prints parse warnings to stdout using print(...), which can contaminate piped
output; update the block that checks "if warnings and force_report_partial:" to
emit diagnostics to stderr instead by passing file=sys.stderr to the printer
(and add an import for sys at the top if missing). Reference the
variables/locations "warnings" and "force_report_partial" and the loop that
iterates "for w in warnings:" in projects/caliper/engine/parse.py when making
this change.
projects/caliper/engine/kpi/import_export.py (1)

18-23: Replace per-record index() calls with bulk helper and validate required fields.

Two issues with this export loop:

  1. Each call performs a single-document index with refresh=True, forcing an index refresh per record. This is orders of magnitude slower than opensearchpy.helpers.bulk for non-trivial record counts and risks overwhelming the cluster. A single refresh=True at the end is typically sufficient.
  2. doc_id is built via rec.get(...) with no validation — missing kpi_id, run_id, or timestamp yields IDs like "None-None-None" that collide across unrelated records and silently overwrite prior documents.
🛠️ Proposed direction
-from projects.caliper.engine.kpi.opensearch_client import build_client
+from opensearchpy.helpers import bulk
+
+from projects.caliper.engine.kpi.opensearch_client import build_client
@@
 def export_kpis_to_index(
     records: list[dict[str, Any]],
     *,
     index: str | None = None,
 ) -> None:
     client = build_client()
     idx = index or os.environ.get("OPENSEARCH_KPI_INDEX", "forge-kpis")
-    for rec in records:
-        # Use kpi_id + run_id + timestamp as id
-        doc_id = f"{rec.get('kpi_id')}-{rec.get('run_id')}-{rec.get('timestamp')}"
-        client.index(index=idx, id=doc_id, body=rec, refresh=True)
+    actions = []
+    for rec in records:
+        missing = [k for k in ("kpi_id", "run_id", "timestamp") if not rec.get(k)]
+        if missing:
+            raise ValueError(f"KPI record missing required id fields: {missing}")
+        doc_id = f"{rec['kpi_id']}-{rec['run_id']}-{rec['timestamp']}"
+        actions.append({"_index": idx, "_id": doc_id, "_source": rec})
+    bulk(client, actions, refresh=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/kpi/import_export.py` around lines 18 - 23, The loop
currently calling client.index(...) per record (in the block using
build_client(), idx variable and doc_id =
f"{rec.get('kpi_id')}-{rec.get('run_id')}-{rec.get('timestamp')}") should be
replaced with an opensearchpy.helpers.bulk call to send all documents in one
request and use a single refresh at the end; before bulking, validate that each
record contains non-empty 'kpi_id', 'run_id', and 'timestamp' (skip or collect
and log/raise for invalid records instead of using rec.get(...) which can
produce "None-None-None" IDs), construct stable ids only for validated records,
build the iterable of action dicts (index, _index=idx, _id=doc_id, _source=rec)
and call helpers.bulk(client, actions, refresh=...) once.
projects/caliper/cli/main.py (1)

91-96: Type annotation mismatch: base_dir: Path can be None.

Since --base-dir has default=None, the parameter may be None; annotate as Path | None to match postprocess_config/plugin_module.

 def main(
     ctx: click.Context,
-    base_dir: Path,
+    base_dir: Path | None,
     postprocess_config: Path | None,
     plugin_module: str | None,
 ) -> None:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/cli/main.py` around lines 91 - 96, The type annotation for
the main function's base_dir parameter is incorrect: update the signature of
main so base_dir is annotated as optional (Path | None) to reflect that the CLI
default can be None (matching postprocess_config and plugin_module); locate the
main function definition and change base_dir: Path to base_dir: Path | None and
ensure any downstream uses handle the None case or use assertions/typing casts
where appropriate.
projects/caliper/engine/file_export/mlflow_backend.py (1)

396-428: Reduce duplication between run_id and new-run branches.

The two with mlflow.start_run(...) blocks differ only in the start_run call; the body is identical. Extract into a single helper or pick start_kw first.

-        meta: dict[str, Any] | None = None
-        client = mlflow.tracking.MlflowClient()
-        if run_id:
-            with mlflow.start_run(run_id=run_id):
-                rid = mlflow.active_run().info.run_id
-                _apply_run_metadata(effective_meta)
-                _apply_log_model(artifact_root, effective_meta, verbose=verbose)
-                _upload_mlflow_files_parallel(
-                    client=client,
-                    run_id=rid,
-                    file_paths=file_paths,
-                    artifact_root=artifact_root,
-                    upload_workers=upload_workers,
-                    verbose=verbose,
-                )
-                tu = mlflow.get_tracking_uri() or ""
-                meta = _capture_mlflow_run_metadata(tu)
-        else:
-            start_kw: dict[str, Any] = {}
-            if run_name:
-                start_kw["run_name"] = run_name
-            with mlflow.start_run(**start_kw):
-                rid = mlflow.active_run().info.run_id
-                _apply_run_metadata(effective_meta)
-                _apply_log_model(artifact_root, effective_meta, verbose=verbose)
-                _upload_mlflow_files_parallel(
-                    client=client,
-                    run_id=rid,
-                    file_paths=file_paths,
-                    artifact_root=artifact_root,
-                    upload_workers=upload_workers,
-                    verbose=verbose,
-                )
-                tu = mlflow.get_tracking_uri() or ""
-                meta = _capture_mlflow_run_metadata(tu)
+        client = mlflow.tracking.MlflowClient()
+        start_kw: dict[str, Any] = {}
+        if run_id:
+            start_kw["run_id"] = run_id
+        elif run_name:
+            start_kw["run_name"] = run_name
+        with mlflow.start_run(**start_kw):
+            rid = mlflow.active_run().info.run_id
+            _apply_run_metadata(effective_meta)
+            _apply_log_model(artifact_root, effective_meta, verbose=verbose)
+            _upload_mlflow_files_parallel(
+                client=client,
+                run_id=rid,
+                file_paths=file_paths,
+                artifact_root=artifact_root,
+                upload_workers=upload_workers,
+                verbose=verbose,
+            )
+            tu = mlflow.get_tracking_uri() or ""
+            meta = _capture_mlflow_run_metadata(tu)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_backend.py` around lines 396 -
428, The two identical mlflow.start_run blocks should be merged: build a single
start_kw dict that sets start_kw["run_id"] = run_id if run_id is truthy,
otherwise set start_kw["run_name"] = run_name when present, then call with
mlflow.start_run(**start_kw) once and run the shared body (call
mlflow.active_run().info.run_id, _apply_run_metadata(effective_meta),
_apply_log_model(...), _upload_mlflow_files_parallel(...), capture tu via
mlflow.get_tracking_uri() and call _capture_mlflow_run_metadata(tu)). This
removes the duplicated body while preserving the differing start_run parameters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72d259f6-d716-4da4-8690-eb10102284de

📥 Commits

Reviewing files that changed from the base of the PR and between a396eac and d6f956c.

📒 Files selected for processing (61)
  • bin/caliper
  • docs/caliper/mlflow-config.example.yaml
  • projects/caliper/README.md
  • projects/caliper/__init__.py
  • projects/caliper/cli/__init__.py
  • projects/caliper/cli/main.py
  • projects/caliper/dash_app/kpi_view.py
  • projects/caliper/engine/__init__.py
  • projects/caliper/engine/ai_eval.py
  • projects/caliper/engine/cache.py
  • projects/caliper/engine/file_export/__init__.py
  • projects/caliper/engine/file_export/artifacts_export_run.py
  • projects/caliper/engine/file_export/mlflow_backend.py
  • projects/caliper/engine/file_export/mlflow_config.py
  • projects/caliper/engine/file_export/mlflow_secrets.py
  • projects/caliper/engine/file_export/runner.py
  • projects/caliper/engine/file_export/s3.py
  • projects/caliper/engine/file_export/types.py
  • projects/caliper/engine/kpi/__init__.py
  • projects/caliper/engine/kpi/analyze.py
  • projects/caliper/engine/kpi/catalog.py
  • projects/caliper/engine/kpi/generate.py
  • projects/caliper/engine/kpi/import_export.py
  • projects/caliper/engine/kpi/opensearch_client.py
  • projects/caliper/engine/kpi/rules.py
  • projects/caliper/engine/label_filters.py
  • projects/caliper/engine/load_plugin.py
  • projects/caliper/engine/model.py
  • projects/caliper/engine/parse.py
  • projects/caliper/engine/plugin_config.py
  • projects/caliper/engine/traverse.py
  • projects/caliper/engine/validation.py
  • projects/caliper/engine/visualize.py
  • projects/caliper/orchestration/__init__.py
  • projects/caliper/orchestration/export.py
  • projects/caliper/orchestration/export_config.py
  • projects/caliper/schemas/ai_eval_payload.schema.json
  • projects/caliper/schemas/kpi_record.schema.json
  • projects/caliper/tests/__init__.py
  • projects/caliper/tests/fixtures/minimal_tree/.caliper_cache/projects_caliper_tests_stub_plugin_v1.json
  • projects/caliper/tests/fixtures/minimal_tree/caliper.yaml
  • projects/caliper/tests/fixtures/minimal_tree/team_a/__test_labels__.yaml
  • projects/caliper/tests/fixtures/minimal_tree/team_a/metrics.json
  • projects/caliper/tests/stub_plugin.py
  • projects/core/library/ci.py
  • projects/skeleton/orchestration/ci.py
  • projects/skeleton/orchestration/config.yaml
  • pyproject.toml
  • specs/009-artifact-post-processing/checklists/requirements.md
  • specs/009-artifact-post-processing/checklists/spec-quality.md
  • specs/009-artifact-post-processing/contracts/cli.md
  • specs/009-artifact-post-processing/contracts/kpi_record.md
  • specs/009-artifact-post-processing/contracts/postprocess_manifest.md
  • specs/009-artifact-post-processing/contracts/test_labels.md
  • specs/009-artifact-post-processing/data-model.md
  • specs/009-artifact-post-processing/plan.md
  • specs/009-artifact-post-processing/quickstart.md
  • specs/009-artifact-post-processing/research.md
  • specs/009-artifact-post-processing/spec.md
  • specs/009-artifact-post-processing/tasks.md
  • vaults/psap-forge-mlflow-export.yaml

Comment thread projects/caliper/dash_app/kpi_view.py Outdated
Comment thread projects/caliper/engine/cache.py Outdated
Comment thread projects/caliper/engine/file_export/s3.py Outdated
Comment on lines +26 to +42
def import_kpis_snapshot(
*,
snapshot_path: Path,
index: str | None = None,
max_hits: int = 10_000,
) -> list[dict[str, Any]]:
"""Download KPIs from OpenSearch into snapshot file."""
client = build_client()
idx = index or os.environ.get("OPENSEARCH_KPI_INDEX", "forge-kpis")
body = {"size": max_hits, "query": {"match_all": {}}}
resp = client.search(index=idx, body=body)
hits = [h["_source"] for h in resp.get("hits", {}).get("hits", [])]
snapshot_path.write_text(
"\n".join(json.dumps(h, ensure_ascii=False) for h in hits) + ("\n" if hits else ""),
encoding="utf-8",
)
return hits

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

OpenSearch default index.max_result_window and recommended API for paginating beyond it

💡 Result:

The default value of index.max_result_window in OpenSearch is 10000. This setting limits the maximum value of from + size in search requests using the from and size parameters for pagination. To paginate beyond this limit without increasing index.max_result_window (which can impact performance), the recommended API is Point in Time (PIT) combined with search_after. PIT freezes the dataset in time for consistent pagination, supports deep pagination efficiently, allows forward and backward navigation, and is not bound to a specific query. Official documentation states: "Point in Time (PIT) with search_after is the preferred pagination method in OpenSearch, especially for deep pagination." Usage overview: 1. Create a PIT: POST /my-index/_search/point_in_time?keep_alive=1m with your initial query to get a pit.id. 2. Subsequent pages: Use the pit with pit.id, same sort, and search_after using the last hit's sort values. Alternatives like plain search_after (stateless, may be inconsistent) or scroll (stateful but memory-intensive, not for frequent user queries) are less recommended for general deep pagination.

Citations:


🏁 Script executed:

cat -n projects/caliper/engine/kpi/import_export.py | head -50

Repository: openshift-psap/forge

Length of output: 1892


Full-index snapshots silently truncate at max_hits without warning.

The code uses client.search(..., body={"size": max_hits, ...}) which returns at most max_hits documents. With the default max_hits=10_000, any index containing more records is silently capped at that limit (equal to OpenSearch's default index.max_result_window). There is no check against resp["hits"]["total"] to detect or warn when truncation occurs.

For reliable full-index snapshots, implement pagination using OpenSearch's preferred Point in Time (PIT) API with search_after. Loop through pages until all records are returned, comparing against resp["hits"]["total"] to ensure completeness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/kpi/import_export.py` around lines 26 - 42,
import_kpis_snapshot currently issues a single search with size=max_hits which
silently truncates results for indexes larger than max_hits; change it to
paginate using OpenSearch Point-In-Time (PIT) + search_after: open a PIT for the
target index (use client.open_point_in_time / client.search with pit), perform
repeated client.search calls with the same pit and a sort+search_after cursor
(using the last hit's sort values) collecting hits until no more are returned,
then close the PIT (client.close_point_in_time); while paginating, compare the
accumulated hit count to resp["hits"]["total"] and log or raise a warning if
they differ, and keep writing the full collected hits to snapshot_path before
returning them (preserve existing parameters snapshot_path, index, max_hits and
function name import_kpis_snapshot).

Comment thread projects/caliper/engine/traverse.py Outdated
Comment thread projects/caliper/orchestration/export.py
Comment thread projects/caliper/orchestration/export.py Outdated
Comment thread projects/caliper/schemas/kpi_record.schema.json Outdated
Comment thread pyproject.toml
@psap-forge-bot

Copy link
Copy Markdown

🔴 Test of 'fournos_launcher submit' failed after 00 hours 12 minutes 32 seconds 🔴

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton long_test
/cluster psap-mgmt

• Failure indicator:

## /logs/artifacts/FAILURE 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
~~ projects/fournos_launcher/toolbox/submit_and_wait/main.py:169
~~ TASK: wait_for_job_completion: Wait for FOURNOS job to complete
~~ ARTIFACT_DIR: /logs/artifacts/001__submit_and_wait
~~ LOG_FILE: /logs/artifacts/001__submit_and_wait/task.log
~~ ARGS:
~~     cluster_name: psap-mgmt
~~     project: skeleton
~~     args:
~~     - long_test
~~     variables_overrides: {}
~~     job_name: ''
~~     namespace: psap-automation
~~     owner: kpouget
~~     display_name: skeleton long_test
~~     pipeline_name: forge-test-only
~~     env:
~~       JOB_TYPE: presubmit
~~       JOB_NAME: pull-ci-openshift-psap-forge-main-fournos
~~       JOB_SPEC: '{"type":"presubmit","job":"pull-ci-openshift-psap-forge-main-fournos","buildid":"2047317078473445376","prowjobid":"f878a2e9-6bfe-4404-b7a4-5d5258571614","refs":{"org":"openshift-psap","repo":"forge","repo_link":"https://github.com/openshift-psap/forge","base_ref":"main","base_sha":"a396eace39f3cfabcac103b48830a71fba438723","base_link":"https://github.com/openshift-psap/forge/commit/a396eace39f3cfabcac103b48830a71fba438723","pulls":[{"number":37,"author":"kpouget","sha":"d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f","title":"[caliper]
~~         Introduce the new post-processing project","head_ref":"009-artifact-post-processing","link":"https://github.com/openshift-psap/forge/pull/37","commit_link":"https://github.com/openshift-psap/forge/pull/37/commits/d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f","author_link":"https://github.com/kpouget"}]},"decoration_config":{"timeout":"23h0m0s","grace_period":"15s","utility_images":{"clonerefs":"us-docker.pkg.dev/k8s-infra-prow/images/clonerefs:v20260421-d25a17867","initupload":"us-docker.pkg.dev/k8s-infra-prow/images/initupload:v20260421-d25a17867","entrypoint":"us-docker.pkg.dev/k8s-infra-prow/images/entrypoint:v20260421-d25a17867","sidecar":"us-docker.pkg.dev/k8s-infra-prow/images/sidecar:v20260421-d25a17867"},"resources":{"clonerefs":{"limits":{"memory":"3Gi"},"requests":{"cpu":"100m","memory":"500Mi"}},"initupload":{"limits":{"memory":"200Mi"},"requests":{"cpu":"100m","memory":"50Mi"}},"place_entrypoint":{"limits":{"memory":"100Mi"},"requests":{"cpu":"100m","memory":"25Mi"}},"sidecar":{"limits":{"memory":"2Gi"},"requests":{"cpu":"100m","memory":"250Mi"}}},"gcs_configuration":{"bucket":"test-platform-results","path_strategy":"single","default_org":"openshift","default_repo":"origin","mediaTypes":{"log":"text/plain"},"compress_file_types":["txt","log","json","tar","html","yaml"]},"gcs_credentials_secret":"gce-sa-credentials-gcs-publisher","skip_cloning":true,"censor_secrets":true,"censoring_options":{"minimum_secret_length":6}}}'
~~       OPENSHIFT_CI: 'true'
~~       JOB_NAME_SAFE: fournos
~~       BUILD_ID: '2047317078473445376'
~~       PULL_PULL_SHA: d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f
~~       PULL_NUMBER: '37'
~~       PULL_BASE_REF: main
~~       REPO_NAME: forge
~~       REPO_OWNER: openshift-psap
~~       PULL_BASE_SHA: a396eace39f3cfabcac103b48830a71fba438723
~~       PULL_TITLE: '[caliper] Introduce the new post-processing project'
~~       PULL_REFS: main:a396eace39f3cfabcac103b48830a71fba438723,37:d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f
~~       PULL_HEAD_REF: 009-artifact-post-processing
~~     status_dest: /logs/artifacts
~~     ci_label: pr37_b2047317078473445376
~~     artifact_dir: /logs/artifacts/001__submit_and_wait
~~ CONTEXT:
~~     final_job_name: forge-skeleton-20260423-141043
~~     manifest_file: /logs/artifacts/001__submit_and_wait/src/forge-skeleton-20260423-141043-manifest.yaml
~~
~~ EXCEPTION: RuntimeError
~~     Job forge-skeleton-20260423-141043 not found in namespace psap-automation
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx


[...]

• Execution logs

@kpouget

kpouget commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton long_test
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

@psap-forge-bot

Copy link
Copy Markdown

🔴 Test of 'fournos_launcher submit' failed after 00 hours 01 minutes 03 seconds 🔴

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton long_test
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

• Failure indicator:

## /logs/artifacts/FAILURE 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
~~ projects/fournos_launcher/toolbox/submit_and_wait/main.py:169
~~ TASK: wait_for_job_completion: Wait for FOURNOS job to complete
~~ ARTIFACT_DIR: /logs/artifacts/001__submit_and_wait
~~ LOG_FILE: /logs/artifacts/001__submit_and_wait/task.log
~~ ARGS:
~~     cluster_name: psap-mgmt
~~     project: skeleton
~~     args:
~~     - long_test
~~     variables_overrides: {}
~~     job_name: ''
~~     namespace: psap-automation-wip
~~     owner: kpouget
~~     display_name: skeleton long_test
~~     pipeline_name: forge-test-only
~~     env:
~~       JOB_TYPE: presubmit
~~       JOB_NAME: pull-ci-openshift-psap-forge-main-fournos
~~       JOB_SPEC: '{"type":"presubmit","job":"pull-ci-openshift-psap-forge-main-fournos","buildid":"2047320525587353600","prowjobid":"11c621b2-ce55-4513-ac9e-e538c25adb70","refs":{"org":"openshift-psap","repo":"forge","repo_link":"https://github.com/openshift-psap/forge","base_ref":"main","base_sha":"a396eace39f3cfabcac103b48830a71fba438723","base_link":"https://github.com/openshift-psap/forge/commit/a396eace39f3cfabcac103b48830a71fba438723","pulls":[{"number":37,"author":"kpouget","sha":"d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f","title":"[caliper]
~~         Introduce the new post-processing project","head_ref":"009-artifact-post-processing","link":"https://github.com/openshift-psap/forge/pull/37","commit_link":"https://github.com/openshift-psap/forge/pull/37/commits/d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f","author_link":"https://github.com/kpouget"}]},"decoration_config":{"timeout":"23h0m0s","grace_period":"15s","utility_images":{"clonerefs":"us-docker.pkg.dev/k8s-infra-prow/images/clonerefs:v20260421-d25a17867","initupload":"us-docker.pkg.dev/k8s-infra-prow/images/initupload:v20260421-d25a17867","entrypoint":"us-docker.pkg.dev/k8s-infra-prow/images/entrypoint:v20260421-d25a17867","sidecar":"us-docker.pkg.dev/k8s-infra-prow/images/sidecar:v20260421-d25a17867"},"resources":{"clonerefs":{"limits":{"memory":"3Gi"},"requests":{"cpu":"100m","memory":"500Mi"}},"initupload":{"limits":{"memory":"200Mi"},"requests":{"cpu":"100m","memory":"50Mi"}},"place_entrypoint":{"limits":{"memory":"100Mi"},"requests":{"cpu":"100m","memory":"25Mi"}},"sidecar":{"limits":{"memory":"2Gi"},"requests":{"cpu":"100m","memory":"250Mi"}}},"gcs_configuration":{"bucket":"test-platform-results","path_strategy":"single","default_org":"openshift","default_repo":"origin","mediaTypes":{"log":"text/plain"},"compress_file_types":["txt","log","json","tar","html","yaml"]},"gcs_credentials_secret":"gce-sa-credentials-gcs-publisher","skip_cloning":true,"censor_secrets":true,"censoring_options":{"minimum_secret_length":6}}}'
~~       OPENSHIFT_CI: 'true'
~~       JOB_NAME_SAFE: fournos
~~       BUILD_ID: '2047320525587353600'
~~       PULL_PULL_SHA: d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f
~~       PULL_NUMBER: '37'
~~       PULL_BASE_REF: main
~~       REPO_NAME: forge
~~       REPO_OWNER: openshift-psap
~~       PULL_BASE_SHA: a396eace39f3cfabcac103b48830a71fba438723
~~       PULL_TITLE: '[caliper] Introduce the new post-processing project'
~~       PULL_REFS: main:a396eace39f3cfabcac103b48830a71fba438723,37:d6f956c729c75b0bd1c2a7b79f1dc2fd9e61b17f
~~       PULL_HEAD_REF: 009-artifact-post-processing
~~     status_dest: /logs/artifacts
~~     ci_label: pr37_b2047320525587353600
~~     artifact_dir: /logs/artifacts/001__submit_and_wait
~~ CONTEXT:
~~     final_job_name: forge-skeleton-20260423-142422
~~     manifest_file: /logs/artifacts/001__submit_and_wait/src/forge-skeleton-20260423-142422-manifest.yaml
~~
~~ EXCEPTION: RuntimeError
~~     Job forge-skeleton-20260423-142422 failed: Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx


[...]

• Execution logs

@kpouget
kpouget force-pushed the 009-artifact-post-processing branch from d6f956c to b03cc14 Compare April 23, 2026 14:27
@kpouget

kpouget commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton long_test
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

@psap-forge-bot

Copy link
Copy Markdown

🟢 Test of 'fournos_launcher submit' succeeded after 00 hours 10 minutes 21 seconds 🟢

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton long_test
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

• Execution logs

@kpouget
kpouget force-pushed the 009-artifact-post-processing branch from b03cc14 to 4dac3a4 Compare April 23, 2026 20:12
@openshift-ci openshift-ci Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Apr 23, 2026
@kpouget
kpouget force-pushed the 009-artifact-post-processing branch from 26afafa to 3d8bfbe Compare April 23, 2026 20:14
@openshift-ci openshift-ci Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Apr 23, 2026
@kpouget

kpouget commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

@psap-forge-bot

Copy link
Copy Markdown

🔴 Test of 'fournos_launcher submit' failed after 00 hours 00 minutes 31 seconds 🔴

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

• Failure indicator:

## /logs/artifacts/FAILURE 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
~~ projects/fournos_launcher/toolbox/submit_and_wait/main.py:169
~~ TASK: wait_for_job_completion: Wait for FOURNOS job to complete
~~ ARTIFACT_DIR: /logs/artifacts/001__submit_and_wait
~~ LOG_FILE: /logs/artifacts/001__submit_and_wait/task.log
~~ ARGS:
~~     cluster_name: psap-mgmt
~~     project: skeleton
~~     args:
~~     - collect_cluster_info
~~     variables_overrides: {}
~~     job_name: ''
~~     namespace: psap-automation-wip
~~     owner: kpouget
~~     display_name: skeleton collect_cluster_info
~~     pipeline_name: forge-test-only
~~     env:
~~       JOB_TYPE: presubmit
~~       JOB_NAME: pull-ci-openshift-psap-forge-main-fournos
~~       JOB_SPEC: '{"type":"presubmit","job":"pull-ci-openshift-psap-forge-main-fournos","buildid":"2047409059165900800","prowjobid":"471b9377-9ede-4472-82e2-4e8f6f7df894","refs":{"org":"openshift-psap","repo":"forge","repo_link":"https://github.com/openshift-psap/forge","base_ref":"main","base_sha":"7b2cd7654692b324777cfbd9ef88da6d31cfddb4","base_link":"https://github.com/openshift-psap/forge/commit/7b2cd7654692b324777cfbd9ef88da6d31cfddb4","pulls":[{"number":37,"author":"kpouget","sha":"3d8bfbe4008b2ea651ad45de1ff52fc702df06fc","title":"[caliper]
~~         Introduce the new post-processing project","head_ref":"009-artifact-post-processing","link":"https://github.com/openshift-psap/forge/pull/37","commit_link":"https://github.com/openshift-psap/forge/pull/37/commits/3d8bfbe4008b2ea651ad45de1ff52fc702df06fc","author_link":"https://github.com/kpouget"}]},"decoration_config":{"timeout":"23h0m0s","grace_period":"15s","utility_images":{"clonerefs":"us-docker.pkg.dev/k8s-infra-prow/images/clonerefs:v20260421-d25a17867","initupload":"us-docker.pkg.dev/k8s-infra-prow/images/initupload:v20260421-d25a17867","entrypoint":"us-docker.pkg.dev/k8s-infra-prow/images/entrypoint:v20260421-d25a17867","sidecar":"us-docker.pkg.dev/k8s-infra-prow/images/sidecar:v20260421-d25a17867"},"resources":{"clonerefs":{"limits":{"memory":"3Gi"},"requests":{"cpu":"100m","memory":"500Mi"}},"initupload":{"limits":{"memory":"200Mi"},"requests":{"cpu":"100m","memory":"50Mi"}},"place_entrypoint":{"limits":{"memory":"100Mi"},"requests":{"cpu":"100m","memory":"25Mi"}},"sidecar":{"limits":{"memory":"2Gi"},"requests":{"cpu":"100m","memory":"250Mi"}}},"gcs_configuration":{"bucket":"test-platform-results","path_strategy":"single","default_org":"openshift","default_repo":"origin","mediaTypes":{"log":"text/plain"},"compress_file_types":["txt","log","json","tar","html","yaml"]},"gcs_credentials_secret":"gce-sa-credentials-gcs-publisher","skip_cloning":true,"censor_secrets":true,"censoring_options":{"minimum_secret_length":6}}}'
~~       OPENSHIFT_CI: 'true'
~~       JOB_NAME_SAFE: fournos
~~       BUILD_ID: '2047409059165900800'
~~       PULL_PULL_SHA: 3d8bfbe4008b2ea651ad45de1ff52fc702df06fc
~~       PULL_NUMBER: '37'
~~       PULL_BASE_REF: main
~~       REPO_NAME: forge
~~       REPO_OWNER: openshift-psap
~~       PULL_BASE_SHA: 7b2cd7654692b324777cfbd9ef88da6d31cfddb4
~~       PULL_TITLE: '[caliper] Introduce the new post-processing project'
~~       PULL_REFS: main:7b2cd7654692b324777cfbd9ef88da6d31cfddb4,37:3d8bfbe4008b2ea651ad45de1ff52fc702df06fc
~~       PULL_HEAD_REF: 009-artifact-post-processing
~~     status_dest: /logs/artifacts
~~     ci_label: pr37_b2047409059165900800
~~     artifact_dir: /logs/artifacts/001__submit_and_wait
~~ CONTEXT:
~~     final_job_name: forge-skeleton-20260423-201722
~~     manifest_file: /logs/artifacts/001__submit_and_wait/src/forge-skeleton-20260423-201722-manifest.yaml
~~
~~ EXCEPTION: RuntimeError
~~     Job forge-skeleton-20260423-201722 failed: Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx


[...]

• Execution logs

@kpouget
kpouget force-pushed the 009-artifact-post-processing branch from 3d8bfbe to 6dcd414 Compare April 23, 2026 20:21
@kpouget

kpouget commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

1 similar comment
@kpouget

kpouget commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

@psap-forge-bot

Copy link
Copy Markdown

🔴 Test of 'fournos_launcher submit' failed after 00 hours 01 minutes 03 seconds 🔴

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

• Failure indicator:

## /logs/artifacts/FAILURE 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
~~ projects/fournos_launcher/toolbox/submit_and_wait/main.py:169
~~ TASK: wait_for_job_completion: Wait for FOURNOS job to complete
~~ ARTIFACT_DIR: /logs/artifacts/001__submit_and_wait
~~ LOG_FILE: /logs/artifacts/001__submit_and_wait/task.log
~~ ARGS:
~~     cluster_name: psap-mgmt
~~     project: skeleton
~~     args:
~~     - collect_cluster_info
~~     variables_overrides: {}
~~     job_name: ''
~~     namespace: psap-automation-wip
~~     owner: kpouget
~~     display_name: skeleton collect_cluster_info
~~     pipeline_name: forge-test-only
~~     env:
~~       JOB_TYPE: presubmit
~~       JOB_NAME: pull-ci-openshift-psap-forge-main-fournos
~~       JOB_SPEC: '{"type":"presubmit","job":"pull-ci-openshift-psap-forge-main-fournos","buildid":"2047412604921450496","prowjobid":"f71c706e-0c12-484b-8ba1-52a51ace9968","refs":{"org":"openshift-psap","repo":"forge","repo_link":"https://github.com/openshift-psap/forge","base_ref":"main","base_sha":"7b2cd7654692b324777cfbd9ef88da6d31cfddb4","base_link":"https://github.com/openshift-psap/forge/commit/7b2cd7654692b324777cfbd9ef88da6d31cfddb4","pulls":[{"number":37,"author":"kpouget","sha":"6dcd41452d95c3a91b68fd702dd789c657fe4856","title":"[caliper]
~~         Introduce the new post-processing project","head_ref":"009-artifact-post-processing","link":"https://github.com/openshift-psap/forge/pull/37","commit_link":"https://github.com/openshift-psap/forge/pull/37/commits/6dcd41452d95c3a91b68fd702dd789c657fe4856","author_link":"https://github.com/kpouget"}]},"decoration_config":{"timeout":"23h0m0s","grace_period":"15s","utility_images":{"clonerefs":"us-docker.pkg.dev/k8s-infra-prow/images/clonerefs:v20260421-d25a17867","initupload":"us-docker.pkg.dev/k8s-infra-prow/images/initupload:v20260421-d25a17867","entrypoint":"us-docker.pkg.dev/k8s-infra-prow/images/entrypoint:v20260421-d25a17867","sidecar":"us-docker.pkg.dev/k8s-infra-prow/images/sidecar:v20260421-d25a17867"},"resources":{"clonerefs":{"limits":{"memory":"3Gi"},"requests":{"cpu":"100m","memory":"500Mi"}},"initupload":{"limits":{"memory":"200Mi"},"requests":{"cpu":"100m","memory":"50Mi"}},"place_entrypoint":{"limits":{"memory":"100Mi"},"requests":{"cpu":"100m","memory":"25Mi"}},"sidecar":{"limits":{"memory":"2Gi"},"requests":{"cpu":"100m","memory":"250Mi"}}},"gcs_configuration":{"bucket":"test-platform-results","path_strategy":"single","default_org":"openshift","default_repo":"origin","mediaTypes":{"log":"text/plain"},"compress_file_types":["txt","log","json","tar","html","yaml"]},"gcs_credentials_secret":"gce-sa-credentials-gcs-publisher","skip_cloning":true,"censor_secrets":true,"censoring_options":{"minimum_secret_length":6}}}'
~~       OPENSHIFT_CI: 'true'
~~       JOB_NAME_SAFE: fournos
~~       BUILD_ID: '2047412604921450496'
~~       PULL_PULL_SHA: 6dcd41452d95c3a91b68fd702dd789c657fe4856
~~       PULL_NUMBER: '37'
~~       PULL_BASE_REF: main
~~       REPO_NAME: forge
~~       REPO_OWNER: openshift-psap
~~       PULL_BASE_SHA: 7b2cd7654692b324777cfbd9ef88da6d31cfddb4
~~       PULL_TITLE: '[caliper] Introduce the new post-processing project'
~~       PULL_REFS: main:7b2cd7654692b324777cfbd9ef88da6d31cfddb4,37:6dcd41452d95c3a91b68fd702dd789c657fe4856
~~       PULL_HEAD_REF: 009-artifact-post-processing
~~     status_dest: /logs/artifacts
~~     ci_label: pr37_b2047412604921450496
~~     artifact_dir: /logs/artifacts/001__submit_and_wait
~~ CONTEXT:
~~     final_job_name: forge-skeleton-20260423-203016
~~     manifest_file: /logs/artifacts/001__submit_and_wait/src/forge-skeleton-20260423-203016-manifest.yaml
~~
~~ EXCEPTION: RuntimeError
~~     Job forge-skeleton-20260423-203016 failed: Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx


[...]

• Execution logs

@kpouget kpouget changed the title [caliper] Introduce the new post-processing project [caliper] Introduce the artifact-exporter Apr 24, 2026
@kpouget

kpouget commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

@psap-forge-bot

Copy link
Copy Markdown

🟢 Test of 'fournos_launcher submit' succeeded after 00 hours 01 minutes 14 seconds 🟢

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip

• Execution logs

@kpouget
kpouget force-pushed the 009-artifact-post-processing branch from 6dcd414 to ae5d1e7 Compare April 24, 2026 09:01
@kpouget

kpouget commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip
/var caliper.export.dry_run: false

@psap-forge-bot

Copy link
Copy Markdown

🔴 Test of 'fournos_launcher submit' failed after 00 hours 01 minutes 03 seconds 🔴

• Link to the test results.

• No reports index generated...

Test configuration:

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip
/var caliper.export.dry_run: false

• Failure indicator:

## /logs/artifacts/FAILURE 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
~~ projects/fournos_launcher/toolbox/submit_and_wait/main.py:169
~~ TASK: wait_for_job_completion: Wait for FOURNOS job to complete
~~ ARTIFACT_DIR: /logs/artifacts/001__submit_and_wait
~~ LOG_FILE: /logs/artifacts/001__submit_and_wait/task.log
~~ ARGS:
~~     cluster_name: psap-mgmt
~~     project: skeleton
~~     args:
~~     - collect_cluster_info
~~     variables_overrides:
~~       caliper.export.dry_run: 'false'
~~     job_name: ''
~~     namespace: psap-automation-wip
~~     owner: kpouget
~~     display_name: skeleton collect_cluster_info
~~     pipeline_name: forge-test-only
~~     env:
~~       JOB_TYPE: presubmit
~~       JOB_NAME: pull-ci-openshift-psap-forge-main-fournos
~~       JOB_SPEC: '{"type":"presubmit","job":"pull-ci-openshift-psap-forge-main-fournos","buildid":"2047601965092835328","prowjobid":"bdfc30d0-f83d-4e5e-a823-365e81f40890","refs":{"org":"openshift-psap","repo":"forge","repo_link":"https://github.com/openshift-psap/forge","base_ref":"main","base_sha":"7b2cd7654692b324777cfbd9ef88da6d31cfddb4","base_link":"https://github.com/openshift-psap/forge/commit/7b2cd7654692b324777cfbd9ef88da6d31cfddb4","pulls":[{"number":37,"author":"kpouget","sha":"ae5d1e77a6fa70188faf7ae398647e66af2aa749","title":"[caliper]
~~         Introduce the artifact-exporter","head_ref":"009-artifact-post-processing","link":"https://github.com/openshift-psap/forge/pull/37","commit_link":"https://github.com/openshift-psap/forge/pull/37/commits/ae5d1e77a6fa70188faf7ae398647e66af2aa749","author_link":"https://github.com/kpouget"}]},"decoration_config":{"timeout":"23h0m0s","grace_period":"15s","utility_images":{"clonerefs":"us-docker.pkg.dev/k8s-infra-prow/images/clonerefs:v20260421-d25a17867","initupload":"us-docker.pkg.dev/k8s-infra-prow/images/initupload:v20260421-d25a17867","entrypoint":"us-docker.pkg.dev/k8s-infra-prow/images/entrypoint:v20260421-d25a17867","sidecar":"us-docker.pkg.dev/k8s-infra-prow/images/sidecar:v20260421-d25a17867"},"resources":{"clonerefs":{"limits":{"memory":"3Gi"},"requests":{"cpu":"100m","memory":"500Mi"}},"initupload":{"limits":{"memory":"200Mi"},"requests":{"cpu":"100m","memory":"50Mi"}},"place_entrypoint":{"limits":{"memory":"100Mi"},"requests":{"cpu":"100m","memory":"25Mi"}},"sidecar":{"limits":{"memory":"2Gi"},"requests":{"cpu":"100m","memory":"250Mi"}}},"gcs_configuration":{"bucket":"test-platform-results","path_strategy":"single","default_org":"openshift","default_repo":"origin","mediaTypes":{"log":"text/plain"},"compress_file_types":["txt","log","json","tar","html","yaml"]},"gcs_credentials_secret":"gce-sa-credentials-gcs-publisher","skip_cloning":true,"censor_secrets":true,"censoring_options":{"minimum_secret_length":6}}}'
~~       OPENSHIFT_CI: 'true'
~~       JOB_NAME_SAFE: fournos
~~       BUILD_ID: '2047601965092835328'
~~       PULL_PULL_SHA: ae5d1e77a6fa70188faf7ae398647e66af2aa749
~~       PULL_NUMBER: '37'
~~       PULL_BASE_REF: main
~~       REPO_NAME: forge
~~       REPO_OWNER: openshift-psap
~~       PULL_BASE_SHA: 7b2cd7654692b324777cfbd9ef88da6d31cfddb4
~~       PULL_TITLE: '[caliper] Introduce the artifact-exporter'
~~       PULL_REFS: main:7b2cd7654692b324777cfbd9ef88da6d31cfddb4,37:ae5d1e77a6fa70188faf7ae398647e66af2aa749
~~       PULL_HEAD_REF: 009-artifact-post-processing
~~     status_dest: /logs/artifacts
~~     ci_label: pr37_b2047601965092835328
~~     artifact_dir: /logs/artifacts/001__submit_and_wait
~~ CONTEXT:
~~     final_job_name: forge-skeleton-20260424-090246
~~     manifest_file: /logs/artifacts/001__submit_and_wait/src/forge-skeleton-20260424-090246-manifest.yaml
~~
~~ EXCEPTION: RuntimeError
~~     Job forge-skeleton-20260424-090246 failed: Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx


[...]

• Execution logs

@coderabbitai coderabbitai 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.

Actionable comments posted: 17

🧹 Nitpick comments (10)
projects/core/notifications/send.py (1)

345-350: Use the module logger and a clearer diagnostic message.

The rest of this module routes diagnostics through logger; this new block drops to print, which bypasses log formatting/levels/handlers and makes the output harder to correlate in CI logs. The marker string "JSON JOB_SPEC" is also cryptic — a short explanation of what's being dumped would help when this fires in production.

♻️ Suggested change
-        try:
-            job_spec = json.loads(os.environ["JOB_SPEC"])
-        except Exception:
-            print("JSON JOB_SPEC")
-            print(os.environ["JOB_SPEC"])
-            raise
+        try:
+            job_spec = json.loads(os.environ["JOB_SPEC"])
+        except Exception:
+            logger.error(
+                "Failed to parse JOB_SPEC as JSON. Raw value:\n%s",
+                os.environ.get("JOB_SPEC"),
+            )
+            raise

Note: if JOB_SPEC can ever contain sensitive values in your CI environment, consider truncating or redacting before logging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/core/notifications/send.py` around lines 345 - 350, Replace the
print-based error handling around json.loads(os.environ["JOB_SPEC"]) with the
module logger: catch the exception and call logger.exception or logger.error
(including the exception) with a clearer diagnostic like "Failed to parse
JOB_SPEC environment variable" and include a safely redacted or truncated
preview of os.environ["JOB_SPEC"] (or note redaction if sensitive) so the
stacktrace and context are routed through the module logger; update the
try/except block that assigns job_spec to use logger.exception(logger.error)
instead of print and re-raise the exception as before.
projects/caliper/engine/model.py (1)

1-1: Module docstring scope vs. file location.

The file is projects/caliper/engine/model.py (which reads as the generic engine data model), but the docstring scopes it to "the file export pipeline (artifact uploads)" and both dataclasses are file-export-specific. If model.py is intended to be the general engine model module, broaden the docstring and consider moving these types to file_export/ (they're already re-exported from file_export/types.py per the summary). Otherwise consider renaming the file to reflect its narrow scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/model.py` at line 1, The module docstring and
contained dataclasses are specific to the file export/artifact upload pipeline
but live in projects/caliper/engine/model.py; either broaden the docstring and
keep the types here (if this truly is the engine-wide model) or move/rename to
reflect narrow scope — specifically, move the file-export dataclasses currently
re-exported from file_export/types.py into that package (or rename model.py to
file_export_models.py) and update all imports/re-exports (references: the two
file-export dataclasses and the re-export in file_export/types.py) so the module
name matches its responsibility and the top-level docstring accurately describes
the content.
projects/caliper/engine/file_export/s3.py (1)

41-60: Prefix normalization is applied for keys but not for the returned URL.

Line 41 uses prefix.rstrip('/') when building keys, but Line 60 returns s3://{bucket}/{prefix} with the un-normalized value. An empty/blank prefix would also yield an initial / in keys (S3 generally disallows this) with no validation. Consider normalizing once at the top and reusing:

-    client = boto3.client("s3")
-    root = source.resolve()
+    client = boto3.client("s3")
+    root = source.resolve()
+    normalized_prefix = prefix.strip("/")
...
-            key = f"{prefix.rstrip('/')}/{rel.as_posix()}"
+            key = f"{normalized_prefix}/{rel.as_posix()}" if normalized_prefix else rel.as_posix()
...
-    return f"s3://{bucket}/{prefix} ({uploaded} files)"
+    return f"s3://{bucket}/{normalized_prefix} ({uploaded} files)"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/s3.py` around lines 41 - 60, Normalize
and validate the prefix once up front (e.g., compute prefix_normalized =
prefix.strip('/') and treat empty string specially) and then use that normalized
value everywhere: build keys using prefix_normalized (if non-empty, join as
"{prefix_normalized}/{rel.as_posix()}", otherwise just rel.as_posix()) in the
code that creates tasks and in the return value (return
"s3://{bucket}/{prefix_normalized}" only when prefix_normalized is non-empty,
otherwise return "s3://{bucket}"); update any references to prefix in the upload
key construction and the final return, leaving helper _upload and tasks logic
unchanged.
projects/caliper/cli/main.py (1)

40-40: Validate --backend values against the supported set in the CLI.

click.Option(multiple=True, type=str) accepts any string, and backends is only filtered to non-empty values. A typo like --backend mflow is silently forwarded to run_file_export, which then tags it as "skipped" / unknown backend in the results. A click.Choice(["s3", "mlflow"], case_sensitive=False) (or an explicit check here that calls _exit_with_help) would give operators immediate, actionable feedback.

Also applies to: 119-126

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/cli/main.py` at line 40, The CLI accepts arbitrary strings
for the --backend option and forwards them to run_file_export where unknown
values are later marked "skipped"; change validation at the CLI level by
replacing the current click.option("--backend", multiple=True, type=str, ...)
with a constrained choice (e.g., click.Choice(["s3","mlflow"],
case_sensitive=False)) or add an explicit validation check right after parsing
that inspects the backends variable and calls _exit_with_help on any unsupported
value; reference the --backend option, the backends variable in main.py, and
run_file_export/_exit_with_help so you validate/reject typos immediately instead
of deferring to run_file_export.
projects/caliper/engine/file_export/runner.py (1)

68-72: Consider classifying unknown backends as failure rather than skipped.

Per FR-017, skipped is meaningful in dry-run/non-selected contexts; an unknown backend name is an operator-configuration error and should not be confused with "skipped on purpose". Reporting it as failure (with the same "unknown backend" detail) makes per-backend aggregation/exit-code policy unambiguous and matches the existing S3 ValueError path on line 91.

Proposed change
-        if b not in ("s3", "mlflow"):
-            results.append(
-                FileExportBackendResult(backend=b, status="skipped", detail="unknown backend")
-            )
-            continue
+        if b not in ("s3", "mlflow"):
+            results.append(
+                FileExportBackendResult(backend=b, status="failure", detail="unknown backend")
+            )
+            continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/runner.py` around lines 68 - 72, The loop
over backends in runner.py currently appends FileExportBackendResult with status
"skipped" for unknown backend names; change this to status "failure" (keeping
detail="unknown backend") so operator-configuration errors are reported as
failures, matching the S3 ValueError path behavior and making
FileExportBackendResult aggregation/exit-code logic unambiguous; update the
branch that checks b not in ("s3", "mlflow") to append status="failure" instead
of "skipped".
projects/caliper/orchestration/export_config.py (2)

49-49: Consider extra="forbid" for top-level orchestration configs to surface YAML typos early.

The vault/secrets nested models use extra="forbid", but the top-level configs (CaliperExportBackendMlflow, CaliperExportBackends, CaliperOrchestrationExportConfig) use extra="ignore". This silently discards typos like mlflow_experiemnt or upload_worker, which would then be hard to diagnose at runtime. Since these models are loaded from operator-authored YAML, stricter defaults would catch misconfigurations early.

Also applies to: 62-62, 70-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/orchestration/export_config.py` at line 49, The top-level
orchestration Pydantic models (CaliperExportBackendMlflow,
CaliperExportBackends, CaliperOrchestrationExportConfig) are using model_config
= ConfigDict(extra="ignore") which silently drops unknown YAML keys; change
these top-level models to use extra="forbid" instead (adjust the model_config
assignment used by those classes or override each class' Config/model_config) so
unknown keys raise validation errors early — note the nested vault/secrets
models already use extra="forbid", so mirror that for the top-level
orchestration configs referenced by model_config on lines around the
CaliperExportBackendMlflow, CaliperExportBackends and
CaliperOrchestrationExportConfig definitions.

90-98: Validator permits backend: {} and backend: {mlflow: null} only to fail with a confusing message.

If a user writes backend: {} (omitting mlflow) or backend: {mlflow: null}, this validator raises ValueError("caliper.export must enable at least one backend ..."). That is the intended outcome, but note that:

  • b.mlflow is not None guard is needed because mlflow is declared mlflow: CaliperExportBackendMlflow | None = None. Consider whether you actually want mlflow to be nullable at all; if backend is specified, requiring a concrete mlflow entry (or rejecting nulls) would give clearer errors.
  • Error wording says "e.g. backend.mlflow.enabled: true" — good, but when mlflow is simply absent the user may not realize the nesting is required.

Minor; current behavior is safe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/orchestration/export_config.py` around lines 90 - 98, The
validator _at_least_one_backend currently has to guard for backend.mlflow being
None because the field mlflow is declared nullable; to make errors clearer,
change the CaliperExportBackendMlflow field to be non-nullable (remove "| None"
and the default None) so callers must supply backend.mlflow, then simplify
_at_least_one_backend to check backend.mlflow.enabled directly (remove the
b.mlflow is not None branch) and raise a single ValueError referencing
"backend.mlflow.enabled: true" when not enabled; if you prefer to keep mlflow
nullable instead, modify the validator to raise a distinct message when b.mlflow
is None (e.g., "backend.mlflow is missing; add backend.mlflow.enabled: true") so
users know the nesting is required.
projects/caliper/engine/file_export/mlflow_config.py (1)

116-123: Simpler, more direct validation for log_model.flavor / log_model.path.

The current str(log_model.get("flavor")).strip() == "" coerces non-strings (e.g. integers) via str(...), masking their wrong type until the later loop at lines 120-123 raises TypeError. The intent ("required and non-empty string") is clearer and safer as:

Proposed simplification
-        if log_model.get("flavor") is None or str(log_model.get("flavor")).strip() == "":
-            raise TypeError("log_model.flavor is required")
-        if log_model.get("path") is None or str(log_model.get("path")).strip() == "":
-            raise TypeError("log_model.path is required")
-        for key in ("flavor", "path", "artifact_path", "loader", "registered_model_name"):
+        for key in ("flavor", "path", "artifact_path", "loader", "registered_model_name"):
             val = log_model.get(key)
             if val is not None and not isinstance(val, str):
                 raise TypeError(f"log_model.{key} must be a string")
+        for key in ("flavor", "path"):
+            val = log_model.get(key)
+            if not (isinstance(val, str) and val.strip()):
+                raise TypeError(f"log_model.{key} is required and must be a non-empty string")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_config.py` around lines 116 - 123,
The validation for log_model.flavor and log_model.path in mlflow_config.py
currently coerces non-strings via str(...) which masks type errors; update the
checks to first ensure the values are instances of str and then that they are
non-empty (e.g., if not isinstance(val, str) or val.strip() == ""), or
alternatively move the existing loop that enforces string types (the keys tuple
check) to run before the "required" presence/emptiness checks so that functions
using log_model (the flavor/path checks) will fail with a clear TypeError for
wrong types and a separate error for empty strings; reference the existing keys
tuple and the flavor/path presence checks to locate where to change.
projects/caliper/engine/file_export/mlflow_backend.py (2)

381-388: Silent filter of non-file paths.

[p for p in paths if p.is_file()] drops missing files and directories without any warning. For an export backend this hurts diagnosability when the upload ends up smaller than expected. Consider logging (at warning) the entries that were dropped, especially in verbose mode.

🔎 Suggested log
-        file_paths = [p for p in paths if p.is_file()]
+        file_paths = [p for p in paths if p.is_file()]
+        if verbose:
+            skipped = [str(p) for p in paths if not p.is_file()]
+            if skipped:
+                logger.warning("Skipping %d non-file path(s): %s", len(skipped), skipped)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_backend.py` around lines 381 -
388, The code silently drops non-file entries when building file_paths with [p
for p in paths if p.is_file()], which hides missing files or directories; update
the logic around the paths -> file_paths filtering in mlflow_backend.py (the
variables/functions to inspect: paths, file_paths, _parallel_workers,
upload_workers, logger, verbose) to detect which entries were filtered out and
emit a logger.warning listing those dropped entries (include the path and reason
such as "missing" or "not a file"); when verbose is True include the full list
in the warning (otherwise a short summary/count) so uploads are diagnosable.

394-426: Collapse the duplicated mlflow.start_run branches.

The if run_id: ... else: ... branches have identical bodies; only the start_run(...) kwargs differ. Build the kwargs once and run the block a single time to avoid drift between the two copies.

♻️ Proposed refactor
-        meta: dict[str, Any] | None = None
-        client = mlflow.tracking.MlflowClient()
-        if run_id:
-            with mlflow.start_run(run_id=run_id):
-                rid = mlflow.active_run().info.run_id
-                _apply_run_metadata(effective_meta)
-                _apply_log_model(artifact_root, effective_meta, verbose=verbose)
-                _upload_mlflow_files_parallel(
-                    client=client,
-                    run_id=rid,
-                    file_paths=file_paths,
-                    artifact_root=artifact_root,
-                    upload_workers=upload_workers,
-                    verbose=verbose,
-                )
-                tu = mlflow.get_tracking_uri() or ""
-                meta = _capture_mlflow_run_metadata(tu)
-        else:
-            start_kw: dict[str, Any] = {}
-            if run_name:
-                start_kw["run_name"] = run_name
-            with mlflow.start_run(**start_kw):
-                rid = mlflow.active_run().info.run_id
-                _apply_run_metadata(effective_meta)
-                _apply_log_model(artifact_root, effective_meta, verbose=verbose)
-                _upload_mlflow_files_parallel(
-                    client=client,
-                    run_id=rid,
-                    file_paths=file_paths,
-                    artifact_root=artifact_root,
-                    upload_workers=upload_workers,
-                    verbose=verbose,
-                )
-                tu = mlflow.get_tracking_uri() or ""
-                meta = _capture_mlflow_run_metadata(tu)
+        meta: dict[str, Any] | None = None
+        client = mlflow.tracking.MlflowClient()
+        start_kw: dict[str, Any] = {}
+        if run_id:
+            start_kw["run_id"] = run_id
+        elif run_name:
+            start_kw["run_name"] = run_name
+        with mlflow.start_run(**start_kw):
+            rid = mlflow.active_run().info.run_id
+            _apply_run_metadata(effective_meta)
+            _apply_log_model(artifact_root, effective_meta, verbose=verbose)
+            _upload_mlflow_files_parallel(
+                client=client,
+                run_id=rid,
+                file_paths=file_paths,
+                artifact_root=artifact_root,
+                upload_workers=upload_workers,
+                verbose=verbose,
+            )
+            tu = mlflow.get_tracking_uri() or ""
+            meta = _capture_mlflow_run_metadata(tu)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_backend.py` around lines 394 -
426, The two identical branches around mlflow.start_run should be collapsed by
preparing a single start_kw dict that conditionally sets
start_kw["run_id"]=run_id if run_id is provided else
start_kw["run_name"]=run_name (or leaves keys out when None), then call with
mlflow.start_run(**start_kw) once and execute the common body (calling
mlflow.active_run().info.run_id to set rid, _apply_run_metadata(effective_meta),
_apply_log_model(artifact_root, effective_meta, verbose),
_upload_mlflow_files_parallel(..., run_id=rid, ...), and meta =
_capture_mlflow_run_metadata(mlflow.get_tracking_uri() or "")) to avoid
duplicating logic and risk of drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@projects/caliper/cli/main.py`:
- Around line 157-171: run_cli currently calls main.main(standalone_mode=False,
prog_name="caliper") but only catches click.MissingParameter, so other Click
errors (BadParameter, UsageError, NoSuchOption, etc.) surface as tracebacks;
broaden the handler to catch click.ClickException (or
click.exceptions.ClickException) instead of only click.MissingParameter, echo
the error message to stderr (click.echo(str(exc), err=True)), print
ctx.get_help() when exc has a ctx attribute, and exit using exc.exit_code; also
remove the redundant "except SystemExit: raise" block since SystemExit will
naturally propagate.

In `@projects/caliper/engine/file_export/mlflow_backend.py`:
- Around line 431-434: The current branch ignores the outer tracking_uri when a
connection dict is provided because it always calls
_run(connection.get("tracking_uri")), discarding the explicit tracking_uri
param; change the logic in the block that handles connection to pass a fallback
value to _run (e.g., use connection.get("tracking_uri", tracking_uri) or
otherwise prefer the provided tracking_uri when connection lacks it) while
keeping the mlflow_connection_env context manager; update the call site that
invokes _run and the docstring for the enclosing function to document the
precedence (connection.tracking_uri first, then explicit tracking_uri) and
reference the symbols connection, tracking_uri, _run, and mlflow_connection_env.
- Around line 189-199: The helper _maybe_param and its captured user_param_keys
are dead code and should be removed to avoid confusion: delete the
user_param_keys assignment and the _maybe_param function, and keep the existing
params handling that logs each parameter via mlflow.log_param (the for-loop over
params.items()); alternatively, if the original intent was to avoid duplicate
logging, replace the for-loop with calls to _maybe_param(str(pk), "" if pv is
None else str(pv)) and ensure user_param_keys is built from
run_metadata.get("parameters") as shown so duplicates are skipped — reference
symbols: _maybe_param, user_param_keys, params, run_metadata, and
mlflow.log_param.
- Around line 367-376: The code currently drops an explicit insecure_tls=True
when a connection dict is provided without an insecure_tls key and uses a broad
except; update the logic in the block that sets insecure = insecure_tls or
bool(connection and connection.get("insecure_tls")) so that when insecure is
true but connection["insecure_tls"] is not present you still set
MLFLOW_TRACKING_INSECURE_TLS (replace the guard `if not connection:` with `if
not (connection and connection.get("insecure_tls")):`), and change the exception
catch around importing urllib3 from a broad `except Exception:` to `except
ImportError:`; this ensures MLFLOW_TRACKING_INSECURE_TLS is set when the
explicit insecure_tls param is passed and only catches the expected import
failure, and keep mlflow_connection_env(connection)/connection_to_env() behavior
unchanged.
- Around line 20-35: Handle Path.relative_to ValueError in both
_mlflow_artifact_subdir and the verbose-logging branch inside log_artifacts:
wrap the call to file_path.resolve().relative_to(artifact_root.resolve()) in a
try/except ValueError and on exception fall back to returning None (for
_mlflow_artifact_subdir) or using only the basename/p.name in the verbose branch
so files outside artifact_root are uploaded under the file basename instead of
aborting; keep other logic unchanged and reference _mlflow_artifact_subdir and
the verbose-logging code path in log_artifacts when applying the change.

In `@projects/caliper/engine/file_export/mlflow_secrets.py`:
- Around line 128-149: The context manager mlflow_connection_env mutates
process-wide os.environ and is not safe to nest or run concurrently; update its
docstring to explicitly state "process-global; not safe to nest or run
concurrently" and then add a simple re-entrancy/concurrency guard around the
save/clear/apply/restore sequence (e.g., use a module-level threading.Lock or
asyncio.Lock) to prevent simultaneous entries; ensure the guard surrounds the
entire try/finally region that uses _ENV_KEYS and connection_to_env so restore
always runs under the same lock.

In `@projects/caliper/engine/file_export/runner.py`:
- Around line 14-27: The current apply_redaction_policy uses fnmatch which
doesn't support ** or path-aware matching; replace the fnmatch.fnmatch checks
with Path.match (e.g., use p.match(g) or pathlib.PurePath.match) so
exclude_globs like "**/secrets/*.yaml" and directory-aware patterns behave as
expected, and remove or relax the p.is_file() gating (since callers already
filter files) so non-file paths are not silently dropped; update the logic
around exclude (the exclude_globs lookup) accordingly and keep the rest of
apply_redaction_policy unchanged.

In `@projects/caliper/engine/file_export/s3.py`:
- Around line 34-42: The loop that builds tasks currently skips non-files and
missing paths silently (using p.is_file()) and will raise ValueError when
fp.relative_to(root) if a file resolves outside the source; update the logic in
the function that iterates over paths to (1) validate each input path p and if
it does not exist or is not a file either recurse into directories (e.g., walk
contents) or explicitly log/raise an error so callers see misconfiguration
instead of a successful count, (2) wrap the fp.relative_to(root) call (or
pre-check with Path.resolve().is_relative_to or equivalent) to catch ValueError
and log/raise a clear error when a resolved file is outside the source root, and
(3) ensure tasks only contains legitimate (Path, key) entries while adding
descriptive logs that include the offending p, root, and prefix to aid debugging
(references: paths loop, p.is_file(), fp.relative_to(root), tasks, prefix,
root).

In `@projects/caliper/orchestration/export.py`:
- Around line 30-32: The function run_from_orchestration_config currently
declares -> int but actually returns parsed YAML (yaml.safe_load(...)) on
success and raises RuntimeError on error; update the return type to reflect the
parsed YAML (e.g., dict[str, Any] | list[Any] | None) so callers get the correct
type, and simplify the call from yaml.safe_load(f.read()) to yaml.safe_load(f);
keep the RuntimeError behavior unchanged if you prefer raising instead of
returning an exit code.
- Around line 30-32: The function run_from_orchestration_config currently
assumes caliper_cfg is a dict and directly indexes caliper_cfg["export"], which
causes a TypeError when caliper_cfg is None; update
run_from_orchestration_config to explicitly validate caliper_cfg at the top
(reject None) with a clear error path: if caliper_cfg is None (or not a dict),
log an explanatory message and raise a ValueError (or TypeError per project
conventions) before any indexing, or change the parameter type to require
dict[str, Any] so callers cannot pass None; ensure the check occurs before any
use of caliper_cfg["export"] to preserve the documented dual-shape acceptance.

In `@projects/skeleton/orchestration/ci.py`:
- Around line 55-60: The finally block in test() can let exceptions from
_caliper_export_at_end() override failures from test_skeleton.test(); change
test to call _caliper_export_at_end() inside a try/except that catches and logs
any export errors (but does not re-raise them), while preserving and
returning/propagating the original result/exception from test_skeleton.test();
specifically, keep invoking test_skeleton.test() as before and wrap
_caliper_export_at_end() so export failures are logged (include context) and
swallowed so they cannot mask the primary test_skeleton.test() exception or
return value.

In `@projects/skeleton/orchestration/config.yaml`:
- Around line 17-39: The config currently sets caliper.export.dry_run: true
which prevents MLflow uploads in CI; update the caliper.export.dry_run value to
false when you’re ready to enable real exports and add a clear rollout note in
the deployment/rollout plan indicating the switch and who owns it; locate the
dry_run key under the caliper -> export -> dry_run block in the config and
change it to false (or make it driven by an env/CI variable) and ensure CI docs
or a README mention toggling this flag as part of the rollout checklist.

In `@specs/009-artifact-post-processing/contracts/cli.md`:
- Around line 8-14: Clarify and fix the CLI spec wording: decide whether the
artifacts export subcommand may load a project plugin per FR-017 and then update
the `--plugin` / `--base-dir` applicability text accordingly—either state
explicitly that `artifacts export` operates purely on a provided `--from PATH`
with no plugin resolution, or mark `--plugin MODULE` as allowed/conditional for
`artifacts export` so the backend can load the plugin to resolve "designated by
the project plugin" paths; adjust the sentence "Not used when no plugin is
loaded (e.g. `artifacts export`)" and the `--base-dir` conditional rules to
reflect the chosen behavior and reference `--plugin`, `--base-dir`,
`--postprocess-config`, and the `artifacts export` / `artifacts export`
subcommand semantics per FR-017.
- Around line 80-89: Add explicit documentation for the --continue-on-partial
flag referenced in the CLI contract: update the artifacts export options table
to include a row for `--continue-on-partial` describing its behavior (e.g.,
"when set, continue processing other backends if one backend fails; exit code
reflects overall status unless set") or, if semantics are undecided, add the row
with a "TBD" note specifying partial-failure policy to be finalized; ensure the
flag name `--continue-on-partial` appears verbatim so readers can find it and
that the table row references its interaction with `--backend` and exit-code
policy mentioned elsewhere.

In `@specs/009-artifact-post-processing/research.md`:
- Line 68: The markdown on the decision line has unbalanced emphasis markers
around the `--reports a,b,c` token; replace the malformed `**`--reports
a,b,c`**** list**` segment with a correctly balanced emphasis pattern (for
example a bold phrase containing the inline code: "**`--reports a,b,c` list**"
or simply "`--reports a,b,c` list") and ensure other tokens mentioned (like
`--report-group <id>`, `--visualize-config`, `--include-label`,
`--exclude-label`) remain unchanged.

In `@specs/009-artifact-post-processing/spec.md`:
- Around line 23-29: The two session blocks are out of chronological order:
"Session 2026-04-26" appears before "Session 2026-04-25"; reorder them so
sessions are ascending by date by swapping the "Session 2026-04-26" block (the
paragraph that starts "Q: How does manifest-only plugin discovery...") with the
"Session 2026-04-25" block (the paragraph that starts "Q: Should the engine
support exporting file artifacts..."), ensuring the headings and their full
paragraphs move together and formatting/spacing remains consistent in
specs/009-artifact-post-processing/spec.md.

In `@specs/009-artifact-post-processing/tasks.md`:
- Line 7: The spec file contains a developer-local absolute path in the Input
line which leaks a user-specific filesystem path; update the text in
specs/009-artifact-post-processing/tasks.md to use a repo-relative path instead
(e.g. change "`Design documents from
`/var/home/kpouget/openshift/forge/specs/009-artifact-post-processing/`" to
"`Design documents from `specs/009-artifact-post-processing/`") so the spec is
reproducible and not tied to a single contributor's workstation.

---

Nitpick comments:
In `@projects/caliper/cli/main.py`:
- Line 40: The CLI accepts arbitrary strings for the --backend option and
forwards them to run_file_export where unknown values are later marked
"skipped"; change validation at the CLI level by replacing the current
click.option("--backend", multiple=True, type=str, ...) with a constrained
choice (e.g., click.Choice(["s3","mlflow"], case_sensitive=False)) or add an
explicit validation check right after parsing that inspects the backends
variable and calls _exit_with_help on any unsupported value; reference the
--backend option, the backends variable in main.py, and
run_file_export/_exit_with_help so you validate/reject typos immediately instead
of deferring to run_file_export.

In `@projects/caliper/engine/file_export/mlflow_backend.py`:
- Around line 381-388: The code silently drops non-file entries when building
file_paths with [p for p in paths if p.is_file()], which hides missing files or
directories; update the logic around the paths -> file_paths filtering in
mlflow_backend.py (the variables/functions to inspect: paths, file_paths,
_parallel_workers, upload_workers, logger, verbose) to detect which entries were
filtered out and emit a logger.warning listing those dropped entries (include
the path and reason such as "missing" or "not a file"); when verbose is True
include the full list in the warning (otherwise a short summary/count) so
uploads are diagnosable.
- Around line 394-426: The two identical branches around mlflow.start_run should
be collapsed by preparing a single start_kw dict that conditionally sets
start_kw["run_id"]=run_id if run_id is provided else
start_kw["run_name"]=run_name (or leaves keys out when None), then call with
mlflow.start_run(**start_kw) once and execute the common body (calling
mlflow.active_run().info.run_id to set rid, _apply_run_metadata(effective_meta),
_apply_log_model(artifact_root, effective_meta, verbose),
_upload_mlflow_files_parallel(..., run_id=rid, ...), and meta =
_capture_mlflow_run_metadata(mlflow.get_tracking_uri() or "")) to avoid
duplicating logic and risk of drift.

In `@projects/caliper/engine/file_export/mlflow_config.py`:
- Around line 116-123: The validation for log_model.flavor and log_model.path in
mlflow_config.py currently coerces non-strings via str(...) which masks type
errors; update the checks to first ensure the values are instances of str and
then that they are non-empty (e.g., if not isinstance(val, str) or val.strip()
== ""), or alternatively move the existing loop that enforces string types (the
keys tuple check) to run before the "required" presence/emptiness checks so that
functions using log_model (the flavor/path checks) will fail with a clear
TypeError for wrong types and a separate error for empty strings; reference the
existing keys tuple and the flavor/path presence checks to locate where to
change.

In `@projects/caliper/engine/file_export/runner.py`:
- Around line 68-72: The loop over backends in runner.py currently appends
FileExportBackendResult with status "skipped" for unknown backend names; change
this to status "failure" (keeping detail="unknown backend") so
operator-configuration errors are reported as failures, matching the S3
ValueError path behavior and making FileExportBackendResult
aggregation/exit-code logic unambiguous; update the branch that checks b not in
("s3", "mlflow") to append status="failure" instead of "skipped".

In `@projects/caliper/engine/file_export/s3.py`:
- Around line 41-60: Normalize and validate the prefix once up front (e.g.,
compute prefix_normalized = prefix.strip('/') and treat empty string specially)
and then use that normalized value everywhere: build keys using
prefix_normalized (if non-empty, join as "{prefix_normalized}/{rel.as_posix()}",
otherwise just rel.as_posix()) in the code that creates tasks and in the return
value (return "s3://{bucket}/{prefix_normalized}" only when prefix_normalized is
non-empty, otherwise return "s3://{bucket}"); update any references to prefix in
the upload key construction and the final return, leaving helper _upload and
tasks logic unchanged.

In `@projects/caliper/engine/model.py`:
- Line 1: The module docstring and contained dataclasses are specific to the
file export/artifact upload pipeline but live in
projects/caliper/engine/model.py; either broaden the docstring and keep the
types here (if this truly is the engine-wide model) or move/rename to reflect
narrow scope — specifically, move the file-export dataclasses currently
re-exported from file_export/types.py into that package (or rename model.py to
file_export_models.py) and update all imports/re-exports (references: the two
file-export dataclasses and the re-export in file_export/types.py) so the module
name matches its responsibility and the top-level docstring accurately describes
the content.

In `@projects/caliper/orchestration/export_config.py`:
- Line 49: The top-level orchestration Pydantic models
(CaliperExportBackendMlflow, CaliperExportBackends,
CaliperOrchestrationExportConfig) are using model_config =
ConfigDict(extra="ignore") which silently drops unknown YAML keys; change these
top-level models to use extra="forbid" instead (adjust the model_config
assignment used by those classes or override each class' Config/model_config) so
unknown keys raise validation errors early — note the nested vault/secrets
models already use extra="forbid", so mirror that for the top-level
orchestration configs referenced by model_config on lines around the
CaliperExportBackendMlflow, CaliperExportBackends and
CaliperOrchestrationExportConfig definitions.
- Around line 90-98: The validator _at_least_one_backend currently has to guard
for backend.mlflow being None because the field mlflow is declared nullable; to
make errors clearer, change the CaliperExportBackendMlflow field to be
non-nullable (remove "| None" and the default None) so callers must supply
backend.mlflow, then simplify _at_least_one_backend to check
backend.mlflow.enabled directly (remove the b.mlflow is not None branch) and
raise a single ValueError referencing "backend.mlflow.enabled: true" when not
enabled; if you prefer to keep mlflow nullable instead, modify the validator to
raise a distinct message when b.mlflow is None (e.g., "backend.mlflow is
missing; add backend.mlflow.enabled: true") so users know the nesting is
required.

In `@projects/core/notifications/send.py`:
- Around line 345-350: Replace the print-based error handling around
json.loads(os.environ["JOB_SPEC"]) with the module logger: catch the exception
and call logger.exception or logger.error (including the exception) with a
clearer diagnostic like "Failed to parse JOB_SPEC environment variable" and
include a safely redacted or truncated preview of os.environ["JOB_SPEC"] (or
note redaction if sensitive) so the stacktrace and context are routed through
the module logger; update the try/except block that assigns job_spec to use
logger.exception(logger.error) instead of print and re-raise the exception as
before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 262b08b0-4167-456a-a46e-91fe6e25d9fa

📥 Commits

Reviewing files that changed from the base of the PR and between d6f956c and ae5d1e7.

📒 Files selected for processing (40)
  • bin/caliper
  • docs/caliper/mlflow-config.example.yaml
  • projects/caliper/README.md
  • projects/caliper/__init__.py
  • projects/caliper/cli/__init__.py
  • projects/caliper/cli/main.py
  • projects/caliper/engine/__init__.py
  • projects/caliper/engine/file_export/__init__.py
  • projects/caliper/engine/file_export/artifacts_export_run.py
  • projects/caliper/engine/file_export/mlflow_backend.py
  • projects/caliper/engine/file_export/mlflow_config.py
  • projects/caliper/engine/file_export/mlflow_secrets.py
  • projects/caliper/engine/file_export/runner.py
  • projects/caliper/engine/file_export/s3.py
  • projects/caliper/engine/file_export/types.py
  • projects/caliper/engine/model.py
  • projects/caliper/orchestration/__init__.py
  • projects/caliper/orchestration/export.py
  • projects/caliper/orchestration/export_config.py
  • projects/core/ci_entrypoint/run_ci.py
  • projects/core/library/ci.py
  • projects/core/library/export.py
  • projects/core/notifications/send.py
  • projects/jump_ci/testing/run
  • projects/skeleton/orchestration/ci.py
  • projects/skeleton/orchestration/config.yaml
  • pyproject.toml
  • specs/009-artifact-post-processing/checklists/requirements.md
  • specs/009-artifact-post-processing/checklists/spec-quality.md
  • specs/009-artifact-post-processing/contracts/cli.md
  • specs/009-artifact-post-processing/contracts/kpi_record.md
  • specs/009-artifact-post-processing/contracts/postprocess_manifest.md
  • specs/009-artifact-post-processing/contracts/test_labels.md
  • specs/009-artifact-post-processing/data-model.md
  • specs/009-artifact-post-processing/plan.md
  • specs/009-artifact-post-processing/quickstart.md
  • specs/009-artifact-post-processing/research.md
  • specs/009-artifact-post-processing/spec.md
  • specs/009-artifact-post-processing/tasks.md
  • vaults/psap-forge-mlflow-export.yaml
✅ Files skipped from review due to trivial changes (16)
  • projects/caliper/init.py
  • projects/caliper/engine/file_export/init.py
  • projects/core/ci_entrypoint/run_ci.py
  • projects/caliper/engine/init.py
  • projects/caliper/engine/file_export/types.py
  • projects/core/library/ci.py
  • docs/caliper/mlflow-config.example.yaml
  • specs/009-artifact-post-processing/contracts/kpi_record.md
  • specs/009-artifact-post-processing/contracts/postprocess_manifest.md
  • vaults/psap-forge-mlflow-export.yaml
  • specs/009-artifact-post-processing/checklists/requirements.md
  • projects/caliper/cli/init.py
  • bin/caliper
  • projects/caliper/orchestration/init.py
  • specs/009-artifact-post-processing/data-model.md
  • projects/caliper/engine/file_export/artifacts_export_run.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • specs/009-artifact-post-processing/contracts/test_labels.md
  • projects/caliper/README.md
  • pyproject.toml
  • specs/009-artifact-post-processing/quickstart.md
  • specs/009-artifact-post-processing/plan.md

Comment on lines +157 to +171
def run_cli() -> None:
try:
rv = main.main(standalone_mode=False, prog_name="caliper")
if isinstance(rv, int) and rv != 0:
sys.exit(rv)
except click.MissingParameter as exc:
msg = exc.format_message()
sub = getattr(exc, "ctx", None)
click.echo(f"Error: {msg}\n", err=True)
if sub is not None:
click.echo(sub.get_help(), err=True)
ec = getattr(exc, "exit_code", 2)
sys.exit(2 if ec is None else int(ec))
except SystemExit:
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm there are no other tests/callers asserting this standalone_mode behaviour.
rg -nP --type=py -C3 'main\.main\(standalone_mode=False'
rg -nP --type=py -C3 'from projects\.caliper\.cli\.main'

Repository: openshift-psap/forge

Length of output: 506


🏁 Script executed:

cat -n projects/caliper/cli/main.py | head -180

Repository: openshift-psap/forge

Length of output: 6587


🏁 Script executed:

rg --type=py -B20 'def run_cli' projects/caliper/cli/main.py

Repository: openshift-psap/forge

Length of output: 696


🏁 Script executed:

rg --type=py 'import.*main' projects/caliper/cli/main.py | head -20

Repository: openshift-psap/forge

Length of output: 46


🏁 Script executed:

fd -t py '.*test.*' | xargs grep -l 'run_cli\|standalone_mode' 2>/dev/null | head -10

Repository: openshift-psap/forge

Length of output: 234


🏁 Script executed:

find . -type f -name "*.py" | xargs grep -l "run_cli" 2>/dev/null

Repository: openshift-psap/forge

Length of output: 95


🏁 Script executed:

find . -type f -name "*.py" | xargs grep -l "test.*caliper.*cli\|caliper.*cli.*test" 2>/dev/null | head -10

Repository: openshift-psap/forge

Length of output: 46


🏁 Script executed:

rg --type=py "standalone_mode" -B2 -A2

Repository: openshift-psap/forge

Length of output: 377


🏁 Script executed:

git log --oneline -n 20 -- projects/caliper/cli/main.py 2>/dev/null | head -20

Repository: openshift-psap/forge

Length of output: 110


Incomplete Click error handling with standalone_mode=False — consider broadening exception catch or using Click's default mode.

run_cli() calls main.main(standalone_mode=False, prog_name="caliper") and only catches click.MissingParameter. With standalone_mode=False, other Click errors (BadParameter, UsageError, NoSuchOption, etc.) will bubble up as unhandled tracebacks instead of friendly usage messages. Either catch click.ClickException broadly or let Click's default standalone mode handle the UX.

Also, except SystemExit: raise on lines 170–171 is redundant—SystemExit propagates naturally when sys.exit() is called.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/cli/main.py` around lines 157 - 171, run_cli currently calls
main.main(standalone_mode=False, prog_name="caliper") but only catches
click.MissingParameter, so other Click errors (BadParameter, UsageError,
NoSuchOption, etc.) surface as tracebacks; broaden the handler to catch
click.ClickException (or click.exceptions.ClickException) instead of only
click.MissingParameter, echo the error message to stderr (click.echo(str(exc),
err=True)), print ctx.get_help() when exc has a ctx attribute, and exit using
exc.exit_code; also remove the redundant "except SystemExit: raise" block since
SystemExit will naturally propagate.

Comment on lines +20 to +35
def _mlflow_artifact_subdir(artifact_root: Path, file_path: Path) -> str | None:
"""
Parent directory of ``file_path`` relative to ``artifact_root``, as a POSIX path.

MLflow ``log_artifact(local, artifact_path=...)`` places the file basename under that
subdirectory, preserving hierarchy when ``artifact_root`` is a directory.
"""
root = artifact_root.resolve()
fp = file_path.resolve()
if root.is_file():
return None
rel = fp.relative_to(root)
parent = rel.parent
if parent == Path("."):
return None
return parent.as_posix()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Python pathlib Path.relative_to walk_up parameter which version introduced

💡 Result:

The walk_up parameter for pathlib.Path.relative_to (and PurePath.relative_to) was introduced in Python 3.12.

Citations:


🏁 Script executed:

#!/bin/bash
# Find and read the mlflow_backend.py file
find . -type f -name "mlflow_backend.py" | head -1

Repository: openshift-psap/forge

Length of output: 120


🏁 Script executed:

#!/bin/bash
# Read the file with line numbers to see the actual code at the mentioned lines
FILE=$(find . -type f -name "mlflow_backend.py" | head -1)
if [ -n "$FILE" ]; then
  wc -l "$FILE"
fi

Repository: openshift-psap/forge

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Check Python version requirements
find . -type f \( -name "pyproject.toml" -o -name "setup.py" -o -name ".python-version" -o -name "setup.cfg" \) | head -5

Repository: openshift-psap/forge

Length of output: 81


🏁 Script executed:

#!/bin/bash
# Read lines 20-35 from the mlflow_backend.py file
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '20,35p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 767


🏁 Script executed:

#!/bin/bash
# Read lines 305-329 from the mlflow_backend.py file (the other location mentioned)
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '305,329p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 1074


🏁 Script executed:

#!/bin/bash
# Check the pyproject.toml for Python version requirements
cat pyproject.toml | head -50

Repository: openshift-psap/forge

Length of output: 1313


🏁 Script executed:

#!/bin/bash
# Search for callers of _upload_mlflow_files_parallel
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
rg "_upload_mlflow_files_parallel" "$FILE" -n

Repository: openshift-psap/forge

Length of output: 205


🏁 Script executed:

#!/bin/bash
# Search for callers in the codebase
rg "_upload_mlflow_files_parallel" --type py -n

Repository: openshift-psap/forge

Length of output: 367


🏁 Script executed:

#!/bin/bash
# Read around line 399 to see how _upload_mlflow_files_parallel is called
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '390,410p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 1125


🏁 Script executed:

#!/bin/bash
# Read around line 417 to see the second call
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '408,428p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 1152


🏁 Script executed:

#!/bin/bash
# Look for any validation/filtering of file_paths before calling the function
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '370,405p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 1761


🏁 Script executed:

#!/bin/bash
# Read more context around the file_paths filtering to understand where paths come from
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '340,385p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 1920


🏁 Script executed:

#!/bin/bash
# Check if there's a function signature for log_artifacts to see where paths originate
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
rg "def log_artifacts" -n -A 10

Repository: openshift-psap/forge

Length of output: 996


🏁 Script executed:

#!/bin/bash
# Check the full context of log_artifacts function parameters
FILE="./projects/caliper/engine/file_export/mlflow_backend.py"
sed -n '342,383p' "$FILE" | cat -n

Repository: openshift-psap/forge

Length of output: 1833


Path.relative_to raises ValueError when a file isn't under artifact_root.

Both _mlflow_artifact_subdir (line 31) and the verbose-logging branch (line 326) call relative_to without catching ValueError. The log_artifacts function only pre-filters paths by is_file() but does not validate they are under artifact_root. If any path in file_paths resolves outside artifact_root, the entire MLflow upload aborts with a bare traceback instead of gracefully falling back to basename-only placement.

The suggested walk_up=True parameter is available only in Python 3.12+, but the project supports Python 3.11. A more portable fix is to use try/except ValueError that falls back to None or p.name for both code paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/caliper/engine/file_export/mlflow_backend.py` around lines 20 - 35,
Handle Path.relative_to ValueError in both _mlflow_artifact_subdir and the
verbose-logging branch inside log_artifacts: wrap the call to
file_path.resolve().relative_to(artifact_root.resolve()) in a try/except
ValueError and on exception fall back to returning None (for
_mlflow_artifact_subdir) or using only the basename/p.name in the verbose branch
so files outside artifact_root are uploaded under the file basename instead of
aborting; keep other logic unchanged and reference _mlflow_artifact_subdir and
the verbose-logging code path in log_artifacts when applying the change.

Comment thread projects/caliper/engine/file_export/mlflow_backend.py
Comment thread projects/caliper/engine/file_export/mlflow_backend.py
Comment thread projects/caliper/engine/file_export/mlflow_backend.py
Comment thread specs/009-artifact-post-processing/contracts/cli.md
Comment thread specs/009-artifact-post-processing/contracts/cli.md
Comment thread specs/009-artifact-post-processing/research.md
Comment thread specs/009-artifact-post-processing/spec.md
Comment thread specs/009-artifact-post-processing/tasks.md
@kpouget

kpouget commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

/test fournos skeleton collect_cluster_info
/cluster psap-mgmt
/var fournos.namespace: psap-automation-wip
/var caliper.export.dry_run: false

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.

1 participant