Implement incremental checkpoints in GitLabWorkflow checkpoint saver
## Problem
`GitLabWorkflow.aput()` (`duo_workflow_service/checkpointer/gitlab_workflow.py`) receives a `new_versions: ChannelVersions` parameter that tells it exactly which state channels changed in the current step, but **ignores it entirely** and always serializes and POSTs the full `checkpoint["channel_values"]` — the entire workflow state.
For a workflow mid-run with a long conversation history, this means every single LangGraph step (even one that only updated `status`) POSTs the full message history, plan, and UI logs.
Compare with how `MemorySaver` handles this:
```python
# MemorySaver.put — incremental
values = c.pop("channel_values")
for k, v in new_versions.items():
self.blobs[(thread_id, ns, k, v)] = self.serde.dumps_typed(values[k])
# only stores channels that changed this step
```
vs current `GitLabWorkflow.aput()`:
```python
payload = {
"checkpoint": checkpoint, # full channel_values every time
...
}
await self._client.apost(...) # new_versions ignored
```
## Proposed solution
1. **`aput`**: Strip `channel_values` from the checkpoint to only the keys present in `new_versions`. Store these as channel blobs alongside the checkpoint header (id, versions, metadata).
2. **Rails API**: Accept channel blobs keyed by `(channel, version)` on write. Return all blobs needed to reconstruct state on read.
3. **`aget_tuple`**: Reconstruct full state by walking the checkpoint chain (via `parent_ts`) and merging blob layers — same reconstruction pattern `MemorySaver` uses in memory.
Infrastructure for per-channel writes already exists: `checkpoint_writes_batch` endpoint (`gitlab_workflow.py:844`) stores interrupt writes per channel. The blob pattern is the same concept applied to every step.
## Impact
- Payload per `aput` call becomes proportional to **what changed** in a step, not total conversation length
- For a step that only updates `status`, the payload shrinks from ~full state to ~a few bytes
- Combines well with async writes: small payloads + non-blocking = minimal overhead per step
## Notes
- Read path becomes a merge over the checkpoint chain instead of a single fetch — one-time cost per session resume, acceptable trade-off
- No change to data residency: checkpoint blobs still go to SM customer's GitLab instance
- `langgraph-checkpoint 2.1.2` is already in use; `new_versions` is already passed by LangGraph on every `aput` call
epic