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