Skip to content

feat(server): expose hook_index in run responses; split HookOutput type - #1099

Merged
shunsuke-shimomura merged 4 commits into
mainfrom
feat/expose-hook-index-in-response
Jul 8, 2026
Merged

shunsuke-shimomura merged 4 commits into
mainfrom
feat/expose-hook-index-in-response

Conversation

@shunsuke-shimomura

@shunsuke-shimomura shunsuke-shimomura commented Jul 7, 2026 •

Copy link
Copy Markdown
Member

Motivation

After #1036 landed the hook_index column on run_outputs, the DB knows which slot in capsula.toml's pre_run / post_run array each hook came from — but the server didn't expose that to clients, and hooks came back in DB-default order. That means:

  • Callers can't tell "1st capture-command vs 2nd capture-command" apart in the response.
  • List order was implementation-defined, so any UI that renders hooks side-by-side had a subtle non-determinism.

Approach

Split the previous dual-purpose HookOutput into two intent-named types:

Type Direction serde Has hook_index?
HookOutputUploaded / HookMetaUploaded POST /api/v1/upload (from CLI) Deserialize No — server assigns via .enumerate()
HookOutputQueried / HookMetaQueried GET run details, search hits Serialize Yes

The upload type dropping hook_index is a small but nice invariant: the client cannot forge or omit an authoritative field, and the server side no longer needs a #[serde(default)] compat shim.

All three hook-fetching SELECTs now pull hook_index and use ORDER BY phase, hook_index, so the response list mirrors capsula.toml's array order.

Tests

New integration test response_exposes_hook_index_and_preserves_array_order:

  • Uploads a 3-element pre_run array: [capture-command, capture-json, capture-command].
  • Fetches GET /api/v1/runs/{id}.
  • Asserts hooks[i].__meta.hook_index == [0, 1, 2] and that content-level ordering (stdout / content payload) matches the sent array.

Existing regression tests (test_hook_outputs_storage, multiple_hooks_with_distinct_configs_coexist, multiple_hooks_with_identical_configs_coexist) continue to pass.

Follow-ups

  • Python client (feat: add Python client package for Capsula server API #974): needs a small mirror to include hook_index: int in its HookMeta. Will land as a separate PR.
  • Query-side hook_index filter (Layer B): adding hook_index: Option<i32> to ParameterMatch so "the Nth capture" can be pinned in queries. Separate PR on top of this one.

Verification

  • cargo test -p capsula-server --test api_tests response_exposes_hook_index_and_preserves_array_order — new test passes
  • cargo test -p capsula-server --test api_tests multiple_hooks — 2/2 existing regressions pass
  • cargo test -p capsula-server --test api_tests test_hook_outputs_storage — existing hook storage test passes
  • cargo clippy -p capsula-server --all-targets -- -D warnings
  • cargo fmt --check -p capsula-server
  • .sqlx/ offline cache regenerated and committed

@shunsuke-shimomura

Copy link
Copy Markdown
Member Author

@shunichironomura
I’m not too confident about the naming, so please let me know if you have any suggestions there as well.

@codecov-commenter

codecov-commenter commented Jul 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 53.84615% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.59%. Comparing base (187a6c5) to head (9cf4f9f).

Files with missing lines Patch % Lines
crates/capsula-server/src/lib.rs 53.84% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1099      +/-   ##
==========================================
- Coverage   49.60%   49.59%   -0.02%     
==========================================
  Files          38       38              
  Lines        3822     3825       +3     
==========================================
+ Hits         1896     1897       +1     
- Misses       1926     1928       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shunsuke-shimomura

Copy link
Copy Markdown
Member Author

#1097 (comment)
Also, if I apply this comment to #1097 , this PR will probably conflict with it.

@shunsuke-shimomura
shunsuke-shimomura marked this pull request as draft July 7, 2026 13:17
@shunichironomura

Copy link
Copy Markdown
Member

@shunsuke-shimomura I don't care much about the naming that is not end-user-facing. That being said, my preference (which LLM happens to agree with) is:

  • HookOutputUpload/HookMetaUpload
  • HookOutputResponse/HookMetaResponse

@shunichironomura

Copy link
Copy Markdown
Member

#1097 (comment) Also, if I apply this comment to #1097 , this PR will probably conflict with it.

@shunsuke-shimomura #1097 is a relatively large internal change while this PR is a small correctness fix. So if this PR can be ready soon, it can be merged first.

@shunsuke-shimomura
shunsuke-shimomura marked this pull request as ready for review July 7, 2026 13:40
@shunsuke-shimomura
shunsuke-shimomura marked this pull request as draft July 7, 2026 13:40
shunsuke-shimomura added a commit to shunsuke-shimomura/capsula that referenced this pull request Jul 7, 2026
Adds `hook_index: int | None = None` to two Python types so the client
tracks the same information the Rust server now emits:

- `ParameterMatch.hook_index` — pins a query to a specific 0-based
  position in the phase's array (mirrors the Rust ParameterMatch field
  from PR ut-issl#973). `to_dict()` includes it only when set, and uses
  `is not None` so `hook_index=0` is preserved on the wire.
- `HookOutput.hook_index` — extracted from the `__meta.hook_index`
  field of each hook entry in the search response (mirrors the Rust
  HookMetaQueried field from PR ut-issl#1099). Absent from the response ->
  `None`, so older servers still parse cleanly.

Docstring updated to document the widened validation on the server
side ("at least one of file / hook_index / parameter") and to spell
out the disambiguation use case for `hook_index`.

Tests: 4 new `to_dict` cases (alone, composed, `hook_index=0` edge,
default omission) and 1 new `_parse_hooks` case for the
missing-`hook_index` fallback. Existing mocked `test_search_runs`
now sends `hook_index` in `__meta` and asserts round-trip parsing.
@shunsuke-shimomura
shunsuke-shimomura force-pushed the feat/expose-hook-index-in-response branch from 199c3ef to 829e532 Compare July 7, 2026 14:40
@shunsuke-shimomura
shunsuke-shimomura marked this pull request as ready for review July 7, 2026 14:48
Splits the previous `HookOutput` (used for both upload and response)
into two intent-named types:

- `HookOutputUploaded` (Deserialize only) — what the CLI sends in
  `POST /api/v1/upload`. No `hook_index`, because the server
  authoritatively assigns positions from each phase's array via
  `.enumerate()`.
- `HookOutputQueried` (Serialize only) — what the server returns from
  run-detail and search responses. Adds `__meta.hook_index: i32` so
  clients can distinguish the Nth invocation of the same hook_id and
  reconstruct the original capsula.toml order.

Same split for `HookMeta` -> `HookMetaUploaded` / `HookMetaQueried`.

Response-side changes:
- All three hook-fetching SELECTs pull `hook_index` and `ORDER BY
  phase, hook_index`, giving a stable, capsula.toml-aligned response
  order.
- `SearchRunResult.pre_run_hooks` / `.post_run_hooks` now hold
  `HookOutputQueried`; template struct `RunDetailTemplate` follows.

New integration test
`response_exposes_hook_index_and_preserves_array_order`:
- Uploads a 3-hook `pre_run` array with two `capture-command`
  instances plus one `capture-json`, positions 0/1/2.
- Verifies `__meta.hook_index` values are exactly `[0, 1, 2]` in
  order, and content-level ordering matches the sent array.

`.sqlx` offline cache regenerated to match the new SELECT columns.

Follow-up (separate PR): mirror `hook_index` in the Python client's
`HookOutput` model.
@shunsuke-shimomura
shunsuke-shimomura force-pushed the feat/expose-hook-index-in-response branch from 829e532 to 0f9ab15 Compare July 8, 2026 09:45
shunsuke-shimomura added a commit to shunsuke-shimomura/capsula that referenced this pull request Jul 8, 2026
Adds `hook_index: int | None = None` to two Python types so the client
tracks the same information the Rust server now emits:

- `ParameterMatch.hook_index` — pins a query to a specific 0-based
  position in the phase's array (mirrors the Rust ParameterMatch field
  from PR ut-issl#973). `to_dict()` includes it only when set, and uses
  `is not None` so `hook_index=0` is preserved on the wire.
- `HookOutput.hook_index` — extracted from the `__meta.hook_index`
  field of each hook entry in the search response (mirrors the Rust
  HookMetaQueried field from PR ut-issl#1099). Absent from the response ->
  `None`, so older servers still parse cleanly.

Docstring updated to document the widened validation on the server
side ("at least one of file / hook_index / parameter") and to spell
out the disambiguation use case for `hook_index`.

Tests: 4 new `to_dict` cases (alone, composed, `hook_index=0` edge,
default omission) and 1 new `_parse_hooks` case for the
missing-`hook_index` fallback. Existing mocked `test_search_runs`
now sends `hook_index` in `__meta` and asserts round-trip parsing.
@shunsuke-shimomura

Copy link
Copy Markdown
Member Author

change HookOutputQueried to HookOutputResponse

@shunichironomura shunichironomura left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I renamed some struct based on my previous feedback. Note that we should change their occurrences in other locations as well, otherwise it won't compile. Apart from that it looks good to me.

Comment thread crates/capsula-server/src/models.rs Outdated
Comment thread crates/capsula-server/src/models.rs Outdated
Comment thread crates/capsula-server/src/models.rs Outdated
@shunsuke-shimomura
shunsuke-shimomura merged commit 928ea32 into main Jul 8, 2026
8 checks passed
@shunsuke-shimomura
shunsuke-shimomura deleted the feat/expose-hook-index-in-response branch July 8, 2026 14:06
shunsuke-shimomura added a commit to shunsuke-shimomura/capsula that referenced this pull request Jul 8, 2026
Adds `hook_index: int | None = None` to two Python types so the client
tracks the same information the Rust server now emits:

- `ParameterMatch.hook_index` — pins a query to a specific 0-based
  position in the phase's array (mirrors the Rust ParameterMatch field
  from PR ut-issl#973). `to_dict()` includes it only when set, and uses
  `is not None` so `hook_index=0` is preserved on the wire.
- `HookOutput.hook_index` — extracted from the `__meta.hook_index`
  field of each hook entry in the search response (mirrors the Rust
  HookMetaQueried field from PR ut-issl#1099). Absent from the response ->
  `None`, so older servers still parse cleanly.

Docstring updated to document the widened validation on the server
side ("at least one of file / hook_index / parameter") and to spell
out the disambiguation use case for `hook_index`.

Tests: 4 new `to_dict` cases (alone, composed, `hook_index=0` edge,
default omission) and 1 new `_parse_hooks` case for the
missing-`hook_index` fallback. Existing mocked `test_search_runs`
now sends `hook_index` in `__meta` and asserts round-trip parsing.
shunsuke-shimomura added a commit to shunsuke-shimomura/capsula that referenced this pull request Jul 8, 2026
Adds `hook_index: int | None = None` to two Python types so the client
tracks the same information the Rust server now emits:

- `ParameterMatch.hook_index` — pins a query to a specific 0-based
  position in the phase's array (mirrors the Rust ParameterMatch field
  from PR ut-issl#973). `to_dict()` includes it only when set, and uses
  `is not None` so `hook_index=0` is preserved on the wire.
- `HookOutput.hook_index` — extracted from the `__meta.hook_index`
  field of each hook entry in the search response (mirrors the Rust
  HookMetaQueried field from PR ut-issl#1099). Absent from the response ->
  `None`, so older servers still parse cleanly.

Docstring updated to document the widened validation on the server
side ("at least one of file / hook_index / parameter") and to spell
out the disambiguation use case for `hook_index`.

Tests: 4 new `to_dict` cases (alone, composed, `hook_index=0` edge,
default omission) and 1 new `_parse_hooks` case for the
missing-`hook_index` fallback. Existing mocked `test_search_runs`
now sends `hook_index` in `__meta` and asserts round-trip parsing.
shunsuke-shimomura added a commit to shunsuke-shimomura/capsula that referenced this pull request Jul 8, 2026
Adds `hook_index: int | None = None` to two Python types so the client
tracks the same information the Rust server now emits:

- `ParameterMatch.hook_index` — pins a query to a specific 0-based
  position in the phase's array (mirrors the Rust ParameterMatch field
  from PR ut-issl#973). `to_dict()` includes it only when set, and uses
  `is not None` so `hook_index=0` is preserved on the wire.
- `HookOutput.hook_index` — extracted from the `__meta.hook_index`
  field of each hook entry in the search response (mirrors the Rust
  HookMetaQueried field from PR ut-issl#1099). Absent from the response ->
  `None`, so older servers still parse cleanly.

Docstring updated to document the widened validation on the server
side ("at least one of file / hook_index / parameter") and to spell
out the disambiguation use case for `hook_index`.

Tests: 4 new `to_dict` cases (alone, composed, `hook_index=0` edge,
default omission) and 1 new `_parse_hooks` case for the
missing-`hook_index` fallback. Existing mocked `test_search_runs`
now sends `hook_index` in `__meta` and asserts round-trip parsing.
shunsuke-shimomura added a commit to shunsuke-shimomura/capsula that referenced this pull request Jul 8, 2026
Adds `hook_index: int | None = None` to two Python types so the client
tracks the same information the Rust server now emits:

- `ParameterMatch.hook_index` — pins a query to a specific 0-based
  position in the phase's array (mirrors the Rust ParameterMatch field
  from PR ut-issl#973). `to_dict()` includes it only when set, and uses
  `is not None` so `hook_index=0` is preserved on the wire.
- `HookOutput.hook_index` — extracted from the `__meta.hook_index`
  field of each hook entry in the search response (mirrors the Rust
  HookMetaQueried field from PR ut-issl#1099). Absent from the response ->
  `None`, so older servers still parse cleanly.

Docstring updated to document the widened validation on the server
side ("at least one of file / hook_index / parameter") and to spell
out the disambiguation use case for `hook_index`.

Tests: 4 new `to_dict` cases (alone, composed, `hook_index=0` edge,
default omission) and 1 new `_parse_hooks` case for the
missing-`hook_index` fallback. Existing mocked `test_search_runs`
now sends `hook_index` in `__meta` and asserts round-trip parsing.
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.

3 participants