Draft: Add session legs API for cross surface DAP sessions (hidden refs)
What does this MR do and why?
Prototype — not intended to merge as is. It has no tests, and its last commit is local-only test scaffolding.
Prototype of the Rails side of the hidden refs storage option from the cross surface DAP sessions ADR, the counterpart to the bespoke package store already prototyped in !252979 (closed). The ADR defers the decision between the two options until a hidden-refs prototype of the same scope exists; together with the client half in gitlab-org/editor-extensions/gitlab-lsp!4032 (closed), this is that prototype.
The difference from the bespoke store is where the package lives. Here it is git data in the project's own repository: the client pushes a synthetic leg commit to refs/duo-session/<workflow_id> and the next surface fetches it, so no bytes pass through Rails and there is no uploader, no object storage location, no workhorse route and no expiry-on-read. What Rails keeps is a thin metadata layer, so clients do not have to discover session state from the repository, and so registration is a point at which the store's invariants can be enforced.
What Rails checks at registration
Registration happens after the client's push has landed, and is the only place Rails can say anything about a leg:
- the session has a project — hidden refs presuppose a repository, so a group-scoped or containerless session cannot use this store at all;
refs/duo-session/<workflow_id>currently points at the submittedleg_sha, read through Gitaly. This is what makes the claim checkable rather than trusted;- the leg commit descends from the last registered leg (its parents include that leg's SHA), otherwise
409. This is the server-side half of the client'spush --force-with-lease: a surface that overwrote the ref concurrently cannot then record itself as the newest leg; - a cap on legs per session, as backpressure against a client looping on registration;
- re-registering the leg already on record is the same fact, not a conflict, so it succeeds without writing.
Notable design decisions
- The chain between legs is not stored in Rails. The leg commit already carries the previous leg as one of its parents — that octopus parent is what makes the chain git data, and ancestry is what orders legs. An earlier revision of this branch had a
parent_leg_shacolumn, mirroring the ADR's table shape;54185cd39c9dremoves it. It was a second tip that could disagree with the repository, and nothing in Rails read it back: the concurrency check reads the parents from git. The row now holds only what git does not have, or what Rails must not take from it. The ADR'sduo_workflow_session_legstable wants the same edit. thread_tsis required and never derived. The bespoke prototype left it optional and best-effort. Only the client's executor knows which checkpoint its leg ended at, and "latest" is exactly the value that races with another leg or with a checkpoint still being flushed — so Rails records it and refuses a leg without it.GET …/legs/latestreports checkpoint drift. A leg records the boundary a client chose to package, while checkpoints advance whenever the session runs anywhere, so the two tips drift apart (the "packages and checkpoints can drift apart" risk in the ADR). The endpoint returns the session's newestthread_tsalongside the leg's, plus acheckpoint_driftflag, so a restoring client can detect it before resetting a worktree to a boundary the agent has already talked past. This is the one fact in the response that exists nowhere in git.- No
refcolumn, no manifest parsing. The leg commit's message is the manifest and the ref names the leg, so the receiving surface discovers branches and HEAD from git itself. Rails stays free of semantic knowledge of what the agent did, and the package format evolves without a Rails release. - Composite identity is resolved to the session owner. Carried over from the bespoke prototype: a CI leg authenticates as a composite-identity service account acting for the person who started the session, so the session is looked up as that person while the
read_duo_workflowability check still runs as the token's user. - No eligibility gate, for the same reason as the bespoke prototype:
Ai::DuoWorkflows::Workflow#environmentrecords the originating surface rather than the flow, so a gate on it answers differently for the same flow depending on which code path created the session. See the "environment names two different things" section of the ADR.
Endpoints:
| Method | Path |
|---|---|
POST |
/ai/duo_workflows/workflows/:workflow_id/legs |
GET |
/ai/duo_workflows/workflows/:workflow_id/legs |
GET |
/ai/duo_workflows/workflows/:workflow_id/legs/latest |
The last commit must be dropped before this merges
96bf810cb891 ("Override GDK's DAP image for testing") is local-only scaffolding for exercising the CI leg, mirroring 84817e1e87ed on the bespoke branch. It points StartWorkflowService::IMAGE_PATH at a locally built image carrying the prototype CLI (duo-cli-hiddenrefs:local, built by the build_image.ts invocation in the client MR) and adds --package-session to the executor command. Both changes carry PROTOTYPE OVERRIDE markers with revert instructions. The runner needs a pull_policy including if-not-present, or it tries to pull the local tag from a registry.
Unlike the bespoke store, packaging in a CI leg is a push rather than an upload, so it needs a credential carrying push_code and an origin that is not the job-token URL (which cannot push code). Both already hold for a coding-environment run — GIT_PASSWORD, which the CLI's credential helper reads, is the workflow OAuth token, and oauth_remote_commands already rewrites origin away from the job-token URL — which is why the commit changes nothing else. That path is unverified: no CI leg has pushed a leg commit yet, on either side of the prototype, and it is the one part of the mechanism neither MR has exercised.
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'. The pre-push hook was skipped for that check knowingly. - No ref cleanup lifecycle.
Repository::RESERVED_REFS_NAMESgainsduo-session, but nothing deletes a session ref. The ADR's plan is deletion on workflow destroy and on archive (30 days) throughRepository#async_delete_refs→ the durableBatchedGitRefUpdatesqueue, plus a low-frequency sweep overrefs/duo-session/*for refs Rails never learned about, followingCi::PersistentRefandMergeRequests::CleanupRefsService. This is the main operational cost of the option and it is not exercised here: verified locally that deleting a session cascades its leg rows away and leaves the ref behind. thread_tsis not verified to be a checkpoint of the session. The ADR asks for that check; it is orthogonal to proving the store works.- No
hideRefs. Whether the namespace can be hidden from advertisement and still fetched by SHA is a Gitaly question, and it only matters once the read-access product question is answered. - No changelog entry, since nothing here is meant to ship.
- Registration is not self-healing. The client MR reports that a leg landing on the ref without being registered leaves every later registration returning
409, because the check compares direct parents rather than ancestry while pushes keep advancing the ref. Both halves of the fix are named there; the Rails half is to check ancestry, which still rejects the case the check exists for.
References
- ADR: gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!6673 (closed)
- Client half of this prototype: gitlab-org/editor-extensions/gitlab-lsp!4032 (closed)
- Bespoke package store prototype, the alternative this is compared against: !252979 (closed)
- Client half of that one: gitlab-org/editor-extensions/gitlab-lsp!3994 (closed)
- Epic: &22804
Database
New table duo_workflow_session_legs, sharded on namespace_id, 7 columns, small, no blob columns. Every read is a single-session lookup:
-- list, and latest (LIMIT 1)
SELECT * FROM duo_workflow_session_legs
WHERE workflow_id = $1
ORDER BY created_at DESC, id DESC;Covered by index_duo_wf_session_legs_on_workflow_id_and_created_at. The unique index on (workflow_id, leg_sha) backs the idempotent retry. namespace_id serves no query and is denormalized only because organization-level tables must carry a non-nullable sharding key referencing namespaces directly. Query plans are not attached: this is a prototype and the table is empty everywhere but a local GDK.
How to set up and validate locally
Validated on a local GDK in a DAP-enabled project, driving the endpoints the way a client would, with real git data. For the same flow through the actual CLI, use the client MR's instructions instead.
-
bundle exec rails db:migrate, then restart Rails so the new API is mounted. -
Create a session:
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" --header "Content-Type: application/json" \ --data '{"project_id":"<project>","goal":"prototype","environment":"chat","workflow_definition":"developer/v1"}' \ --url "$GDK/api/v4/ai/duo_workflows/workflows" -
Build and push a leg commit the way a client would — tree from a throwaway index seeded from the real one, parents = HEAD plus the branches the leg moved (plus the previous leg, from the second leg on), message = the manifest:
cp .git/index /tmp/leg.index GIT_INDEX_FILE=/tmp/leg.index git add -A TREE=$(GIT_INDEX_FILE=/tmp/leg.index git write-tree) LEG=$(git commit-tree "$TREE" -p "$(git rev-parse HEAD)" -m '{"schema":1,"head":"refs/heads/duo/feature"}') git push origin "$LEG:refs/duo-session/<id>" --force-with-lease="refs/duo-session/<id>:" -
Register it, list the session's legs, and resolve the newest one:
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" --header "Content-Type: application/json" \ --data "{\"leg_sha\":\"$LEG\",\"thread_ts\":\"1f0a0001-0000-7000-8000-000000000001\"}" \ --url "$GDK/api/v4/ai/duo_workflows/workflows/<id>/legs" curl --header "PRIVATE-TOKEN: $TOKEN" --url "$GDK/api/v4/ai/duo_workflows/workflows/<id>/legs" curl --header "PRIVATE-TOKEN: $TOKEN" --url "$GDK/api/v4/ai/duo_workflows/workflows/<id>/legs/latest" -
Restore on a "different surface" — a fresh clone:
git fetch origin "refs/duo-session/<id>:refs/duo-session-local/<id>/incoming/leg" git log -1 --format=%B "$LEG" # the manifest git update-ref refs/heads/duo/feature "$LEG^1" # branches from it git checkout "$LEG" && git reset --mixed "$LEG^1" # dirty worktree, git stash style
What this established
A full CLI → CI → register round trip: the fresh clone reproduced the committed work, the unstaged edit and the untracked file, with Rails having stored nothing but a SHA and a thread_ts — every object arrived through ordinary fetch negotiation, with no bundle prerequisite chain to replay. That clone then produced a second leg (-p HEAD -p <leg1>), pushed it with --force-with-lease and registered it, and Rails accepted it because git's ancestry, not a stored pointer, showed it chaining onto leg 1.
Both halves of the concurrency control hold: a stale-lease push was rejected client side (! [rejected] … (stale info)), and force-pushing past it and registering anyway returned 409.
Also checked: a SHA the ref does not point at, and a SHA absent from the repository, both 422; a malformed SHA and a missing thread_ts, 400; unknown session 404; unauthenticated 401; a retried registration returning the same row rather than a duplicate; the leg cap; a group-scoped session rejected for having no repository; and with a later checkpoint header present, legs/latest returning checkpoint_drift: true. The two-leg round trip and the 409 were re-run after parent_leg_sha was removed.
Two of the ADR's open questions get answers:
refs/duo-session/*is pushable. A real push through GitLab succeeded — pre-receive did not refuse the synthetic leg commit.Gitlab::GitAccess#check('git-receive-pack', …)allows the ref for a Developer, and the negative control (the same push withoutpush_code) raisesForbiddenError, which also confirms the ADR's "packaging requires push access" cost.- Multi-leg sessions need no chain management, which is the row where the bespoke store needs cumulative bundles or client-side replay. The client MR takes this further, restoring a two-leg session into a third, previously uninvolved clone.
Still unverified: the CI leg's push, as described above; whether DWS resumes cleanly when the second leg arrives from a different surface than the one that suspended the session; and whether JiraVerificationCheck (which runs on all changes, unlike the branch/tag-only push-rule checks) rejects a leg commit.
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.