Draft: Add session packages API for cross surface DAP sessions
What does this MR do and why?
Prototype — not intended to merge as is. It has no tests.
Prototype of the Rails side of the cross surface DAP sessions ADR: Rails stores and serves stable boundary packages, so a DAP session started on one surface (Duo CLI, IDE chat) can be resumed on another compatible one (CI runner) and back.
Packaging stays a client responsibility — the client builds a git bundle against its own baseline ref namespace — so the blob is opaque here and Rails needs no semantic knowledge of what the agent did. The record holds only what serving it requires: session, thread_ts, the blob pointer and its retention metadata, following the artifact-with-TTL pattern of Ci::JobArtifact#expire_at.
Notable design decisions:
- No
refcolumn. A bundle already describes the refs it carries (git bundle list-heads), and a single column could not represent the several divergent refs a boundary may span. The earlier ADR draft had one; it was dropped. - No eligibility gate. An earlier revision rejected anything but
chat/chat_partialwith a400, mirroring the ADR constraint that a migrated session must be resumable. It was removed in72d9ea7ab009. The only field available to check,Ai::DuoWorkflows::Workflow#environment, records the originating surface rather than the flow's declared environment, and its writers are a client-supplied value, a value hardcoded in Rails' own foundational flow definitions, and a'web'constant. The same flow is therefore judged eligible or not depending on which path created the session, and any client could opt any flow in by assertingchat. A check with that shape enforces nothing while looking like it does, so it is left out until a flow-level fact is persisted to gate on — see the "environment names two different things" section of the ADR. - Composite identity is resolved to the session owner. Packages are scoped to the session owner, but a CI leg authenticates as a composite-identity service account acting for the person who started the session, so looking the session up under the token's own user made every upload from a runner
404.cb50efd44198resolves the owner throughGitlab::Auth::Identity#scoped_userfor that case only; theread_duo_workflowability check still runs as the token's user. This answers one of the ADR's open questions. - Expiry is honoured on read. Expired packages are invisible in listings and return
404on download, so no client can rely on a package whose blob may already be gone. - New
duo_session_packagesstorage location rather than reusinguploads, to keep bundles of repository content out of thepublic/tree.
Endpoints:
| Method | Path |
|---|---|
POST |
/ai/duo_workflows/workflows/:workflow_id/packages |
GET |
/ai/duo_workflows/workflows/:workflow_id/packages |
GET |
/ai/duo_workflows/workflows/:workflow_id/packages/:package_id|latest/download |
A note on the commit history
545324611429 ("Normalize session environment before eligibility check") predates the decision above, so its subject now advertises a gate that no longer exists: 72d9ea7ab009 removes both the gate and the #normalized_environment helper that commit added for it. Left in history rather than rewritten, since the reasoning is part of what the prototype found out.
Deliberately incomplete
- No specs at all. This is the main reason the MR is a draft. It also means the
permissions-verifycheck fails: each new endpoint needs its ownit_behaves_like 'authorizing granular token permissions'. - No expiry worker.
expire_atis set and enforced on read, but blobs are never reaped. A cron worker deleting expired rows is the follow-up. - Uploads are not workhorse accelerated. This route is not in workhorse's route table, so Rack parses the multipart in Ruby (hence the new
API::Validations::Types::MultipartFile). A production implementation would add the workhorse route plus direct upload to object storage and switch toWorkhorseFile. - No
config/gitlab.yml.exampleentry for the newduo_session_packagesstorage location yet.
Client side
The client half of the prototype is gitlab-org/editor-extensions/gitlab-lsp!3994 (closed) (draft). It records the baseline refs, builds the package (manifest.json + session.bundle + uncommitted.patch) and drives these three endpoints, which is what establishes that an opaque blob plus a thread_ts is enough for Rails to move a session between surfaces.
To exercise the CI leg, the DAP runner needs an image carrying that CLI build. From a gitlab-lsp checkout:
E2E_IMAGE_TAG=duo-cli-crosssurface:local mise exec -- bun packages/cli/test/production-headless-e2e/docker/build_image.tsPointing StartWorkflowService::IMAGE_PATH at that tag (and adding --package-session to the executor command so a CI session packages itself) is local-only test scaffolding and is intentionally not part of this MR. The runner needs a pull_policy including if-not-present, otherwise it tries to pull the local tag from a registry.
References
- ADR: gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!6673 (closed)
- Client prototype: gitlab-org/editor-extensions/gitlab-lsp!3994 (closed)
- Epic: &22804
Database
New table duo_workflow_session_packages, sharded on namespace_id, ~7 columns, small. Queries introduced are all single-session lookups on (workflow_id, created_at DESC):
-- list (API list endpoint)
SELECT * FROM duo_workflow_session_packages
WHERE workflow_id = $1 AND (expire_at IS NULL OR expire_at >= $2)
ORDER BY created_at DESC, id DESC;
-- latest (download endpoint)
SELECT * FROM duo_workflow_session_packages
WHERE workflow_id = $1 AND (expire_at IS NULL OR expire_at >= $2)
ORDER BY created_at DESC, id DESC LIMIT 1;Both are covered by index_duo_wf_session_packages_on_workflow_id_and_created_at. Query plans are not attached yet, since this is a prototype and the table is empty everywhere but a local GDK — happy to add them when this turns into a real MR.
How to set up and validate locally
Validated on a local GDK in a DAP-enabled project.
-
bundle exec rails db:migrate, then restart Rails. Note the new storage location means every Rails process needs restarting, Sidekiq included — a stale worker raisesGitlab::Configs::MissingConfig: option 'duo_session_packages' not definedin anything touchingObjectStorage::Config. -
Create a session:
curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \ -d '{"project_id":"<project>","goal":"prototype","environment":"chat","workflow_definition":"developer/v1"}' \ "$GDK/api/v4/ai/duo_workflows/workflows" -
Build a bundle the way a client would, and upload it:
git bundle create /tmp/session.bundle --all curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" \ -F "file=@/tmp/session.bundle" -F "thread_ts=thread-aaa" \ "$GDK/api/v4/ai/duo_workflows/workflows/<id>/packages" -
List them, then download the newest and rehydrate it on a "different surface":
curl -s -H "PRIVATE-TOKEN: $TOKEN" "$GDK/api/v4/ai/duo_workflows/workflows/<id>/packages" curl -s -o /tmp/out.bundle -H "PRIVATE-TOKEN: $TOKEN" \ "$GDK/api/v4/ai/duo_workflows/workflows/<id>/packages/latest/download" git clone --bare /tmp/out.bundle /tmp/restored git --git-dir=/tmp/restored for-each-ref
Observed: the download is byte-identical (md5) to the upload, git bundle verify reports a complete history, and the clone reconstructs every branch and file the session produced. Also checked: multiple packages per session, latest resolution, the thread_ts filter, unauthenticated → 401, unknown session → 404, empty file → 400, and a back-dated expire_at makes the package vanish from listings and 404 on download. Since the eligibility gate was removed, sessions in every environment (web, ambient, ide, chat) return 201, where web/ambient returned 400 before.
End to end, CI → Duo CLI
One full round trip was exercised with the client prototype and a locally built CLI image, which is also what proves the composite-identity upload path:
- Started a
developer/v1session withstart_workflow: true(session1169), which dispatched a DAP CI pipeline. - The CI leg logged
Snapshotted baseline (99 refs), thenBundled 1 commit, and uploaded package15(21504 B,hasBundle:true hasPatch:true) as the composite-identity service account. GET .../packages/latest/downloadreturned a tar holdingmanifest.json,session.bundleanduncommitted.patch.- Restoring into a fresh clone fetched into
refs/duo-session/1169/incoming/*, leavingmainuntouched, then reproduced both the committed file and the untracked one from the CI leg. The commit the CI leg made exists nowhere in the canonical repository.
Still unverified: whether DWS resumes cleanly when the second leg arrives from a different surface than the one that suspended the session. That is a DWS-side question rather than a Rails one, but it is what decides whether the mechanism is usable end to end.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.