DocsThe desk server
The desk server
The desk server keeps the code, the issues and the decisions of a repository in one place (#32, #49). This page describes its parts, its tables and its HTTP API. Each section names the issue of its unit.
The objects and the stores (#50)
src/server/objects.ts: a blob, a tree and a change are objects. The id of an object is the SHA-256 of its type, its length and its bytes. A tree and a change have one canonical form, so equal content has one id.src/server/blobs.ts:Blobskeeps bytes under a key (MemoryBlobs,FsBlobs).ObjectStorestores an object underobjects/<type>/<two characters>/<id>and refuses an object whose bytes have another id.src/server/trees.ts:buildTreeturns a map of files into a root tree, andflattenTreeturns it back.
The write path of a change (#52)
src/server/changes.ts has Changes:
| Method | What it does |
|---|---|
write(change) |
Stores the change object and adds its row to changes. The tree must be in the store, and each parent must be a change of the repository. |
commit(files, meta) |
Builds the tree of a snapshot, then writes the change. |
get(id), has(id) |
Read the row of a change. |
files(id) |
Gives every file of the snapshot of a change. |
index(id) |
Makes the row of a stored change again, for example after a restore of the table. |
The object is written before the row. The row holds nothing that the object does not hold, so a lost table is made again from the store. A second write of the same change adds nothing.
The merge of two trees (#54)
src/server/merge.ts: mergeTrees(store, base, ours, theirs) gives a merged tree and a list of conflicts.
- A folder that is one object on the sides is not read.
- When both sides changed a file, the lines are merged with
node-diff33.2.1. The package loads at the first such file. - A conflict has a path, a kind and its three sides. The kinds are
content,binary(a file that is not UTF-8, has a NUL byte, is over 4 MiB, or is a link),mode,delete-editandtype. The merged tree holds the sideoursat a conflict. - Two files made at one path with no base are a conflict.
conflictTextgives the file with the markers of the conflict.
Landing a change on main (#55)
src/server/land.ts: land(db, objects, repoId, change) puts a change onto main.
| Case | Result |
|---|---|
main does not exist and the change has no parent |
landed: the change becomes main, log entry 1. |
main is an ancestor of the change |
landed: main moves to the change. |
main moved after the change was made |
The change merges with the head over its parent. A clean merge makes a new change on the head with the author, the message, the time and the intent of the change: landed with rebased: true. A conflict gives conflict with the paths, and nothing moves. |
The change landed before, or main has it in its history |
present. |
The ref moves with compare-and-swap, and the log entry is written in the same transaction. A writer that loses the swap reads the new head and tries again, at most 100 times.
Tables (migration 004_landing.sql):
changes.generation: 1 for a change with no parent, else 1 plus the largest generation of its parents.Changes.isAncestorwalks only the generations between two changes.change_log: one row for each landing, the oldest first.seqis the largest number plus one under the lock of the ref, so the numbers are dense, and one landing of a history adds many entries in one move.submittedis the change that the author sent, and it is unique, so a retry finds its entry.
Two open changes on the same files (#96)
A work folder of the client has an id, workspace in .desk-work/work.json; desk checkout makes it, and a folder
made before gets it at its first desk change. The newest change of a folder that has not landed is its open change.
desk change sends the id with the change (POST /v1/code/<repo>/changes {"id", "workspace"}). The server keeps one
row for each folder in open_changes, with the paths that the change touches against its first parent, and answers the
open changes of other folders that touch one of those paths. The client prints one line for each:
warning: the open change 1a2b3c4d5e6f of agent-gpuscale for #79 also changes src/a.ts (sent 2026-10-22 14:05 UTC)
Checked live on 2026-10-22 with two work folders of munhq/desk: the second desk change named the open change of the first, its author and the file. A landing of the change removes its row. A row older than 14 days is work that stopped, and warns nobody.
GET /v1/code/<repo>/overlaps answers each pair of open changes that touch the same paths, and the board of the UI
shows each pair as an amber card at the start of the lane of the repository.
Keys, signatures and land rules (#98)
Each identity registers Ed25519 public keys: GET, POST {"publicKey"} and DELETE /v1/keys/<id> on the server act
on the keys of the caller. desk key new in a work folder makes a pair, writes the private key to a file with mode
600 (~/.config/desk/keys/<server>-<identity>.pem, or --out; encrypted with the passphrase of the variable that
--passphrase-env names), and registers the public key. desk key list and desk key revoke <id> do the rest. A key
file is never written over.
desk change signs with the key in DESK_KEY (the PEM, for example sealkeep run -e DESK_KEY=<name> -- desk change ...)
or in the file of DESK_KEY_FILE, with DESK_KEY_PASSPHRASE for an encrypted key. The signature covers the text
desk-change-v1, the repository and the id of the change, so it does not hold for another repository. The server checks
it against the active keys of the author before it indexes the change, refuses a signature that does not verify (422),
and keeps the key that signed in changes.signed_by. node dist/server/admin.js require-signature <owner/repo> on
makes the server refuse a change with no signature.
A land rule names globs of paths and the identities that may land a change that touches one of them:
node dist/server/admin.js land-rule add <owner/repo> 'infra/**,.desk/**' agent-desk,owner, land-rule list and
land-rule remove. A landing by another identity answers refused with the paths and the rule; the paths are the
ones that the change touches against its first parent. The Config view of the UI shows the keys of each identity,
whether a repository requires signatures, and its land rules.
A rule can leave some kinds of change to any writer, so the named identities review the changes of code only (the
approval rules of Antique in anthropic.com/research/redesigning-source-control): land-rule add <owner/repo> <globs> <identities> --skip comments,docs (migration 027_land_rule_skip.sql). src/server/change-kind.ts gives the kind of
the change of each file:
docs: a.md,.mdxor.rstfile.comments: the lines of code stay the same, in the same order, and only full-line comments or blank lines change://or a one-line/* */in C-like files,#in Python, shell, YAML, TOML and BUCK (not#!),--in SQL.code: every other change, also an added or removed file, an inline comment, a block comment over lines, a Rust attribute, a file of an unknown language and bytes that are no UTF-8. The classes never call code a comment.
The log, history, blame and search (#56)
| Function | Answer | Limit |
|---|---|---|
log(db, repoId, { before, limit, intents }) |
The landings, the newest first. intents keeps the landings whose change names one of those issues. |
1 to 500 entries, 50 by default; 50 issues at most. |
pathLog(db, objects, repoId, path, { before, limit }) |
The landings that changed a path, and next, the log number to go on from. |
It reads at most 5,000 landings. |
blame(db, objects, repoId, path, change) |
Each line of a file with the change that made it. | It walks at most 5,000 first parents and reads files of at most 4 MiB; then truncated is true. A file over 4 MiB is refused. |
readFile, listDir |
A file or a folder of a snapshot. | One object, as large as the store holds it. |
search(objects, tree, query, opts) |
The lines of the text files that hold a plain query. | 200 matches, 20,000 files, 64 MiB read, 1 MiB for one file; then truncated is true. |
A query is plain text. A pattern that a user writes could run on the server for as long as the user wants. A path with an empty name, . or .. is refused.
The code routes of the HTTP API (#57)
All routes are under /v1/code/<owner>/<repo>/ and need Authorization: Bearer <token>. Each repository keeps its objects under its own prefix repo-<id>/.
| Route | Right | Answer |
|---|---|---|
POST missing {"objects":[{"type","id"}]} |
write | {"missing":[...]}; at most 1,000 entries. |
PUT objects/<type>/<id> |
write | 201 when stored, 200 when it existed. The bytes must have the id; a tree and a change must decode; the author of a change must be the caller. |
GET objects/<type>/<id> |
read | The bytes. |
POST fetch {"objects":[{"type","id"}]} |
read | Many objects in one binary body (see the thin client); at most 1,000 entries and 64 MB. A cut answer has the header x-desk-cut: 1. |
POST closure {"at","have"} |
read | A change and every folder and file under its tree, in one streamed body of frames (see the thin client). |
POST objects (a body of frames) |
write | {"stored"}. Every id, folder and change author is checked before anything is stored. |
GET whoami |
read | {"name","kind"} of the caller. |
POST changes {"id"} |
write | The indexed change. Every object of its tree must be stored. |
POST land {"change"} |
merge | The result of land; 409 for a conflict. |
GET head |
read | {"head","rev"}. |
GET log?path=&before=&limit=&intent= |
read | The log, or the log of a path. intent=129,130 keeps the landings of those issues (the trace of an issue reads its changes this way). |
GET changes/<id> |
read | The change. |
GET files/<at>/<path> |
read | A file as bytes (x-desk-mode, x-desk-id), or a folder as {"entries"}. |
GET blame/<at>/<path> |
read | {"lines","truncated"}. |
GET search/<at>?q=&under=&case=1&max= |
read | {"matches","truncated"}. |
<at> is main or a change id. A server with no object store answers 501. GET /v1/repos lists the repositories that the caller may read, with the head of main and the count of landings. CodeClient (src/server/code-client.ts) makes the objects of a change on the machine of the caller and uploads only the missing ones.
The run routes (#64)
The routes of the runs are under /v1/runs/<owner>/<repo>/; docs/runs.md describes the queue, the runner and the settings.
The import of a git history (#58)
importGit({ dir, ref, db, objects, repoId }) reads a git repository with git rev-list and one git cat-file --batch process. Each commit becomes one change with its parents, its author (Name <email>), its author time in UTC and its message. The first-parent line of the ref becomes the log through landHistory, in one transaction. git_commits (migration 005_git_commits.sql; migration 006_git_commits_many.sql lets one commit hold several changes, which an export can make) maps each commit to its change, so a second run converts only the new commits. A commit with several changes gives, in the first-parent line, the change that the next change has as its first parent.
The import stops with the reason on a name that is not UTF-8, a submodule, a shallow clone, and a first-parent line in which two commits make one change. With --drop-submodules (desk repo import and desk repo move, the query submodules=drop of the import routes), the import leaves each submodule entry out of the trees, and the result and the log name its path. A submodule entry holds a commit of another repository and no file of this one, so the trees keep every file. The copy on GitHub keeps the imported git commits as they are.
Measured on 2026-10-15 on Postgres 18.4 and a file store, with three real repositories: 113 commits in 0.9 s, 606 commits in 4.7 s, 700 commits in 9.1 s; a second run about 30 ms. Nine snapshots equal git ls-tree -r byte for byte.
The export to git (#59)
exportGit({ dir, branch, db, objects, repoId }) writes each change that has no commit as a git commit with git fast-import, and moves the branch to the commit of the head of main. A branch that holds a commit which the history of main does not hold is not moved, and the export stops with the reason. A change that was imported keeps its commit. A commit has the parents, the tree, the author and the time of its change; an author with no email gets <name>@desk.invalid, or <name>@<emailHost> when the call sets emailHost. pushGit(dir, remote, branch) pushes the branch as a fast-forward, and a remote with other commits refuses it.
The S3 store (#53)
S3Blobs (src/server/s3.ts) implements Blobs over an S3 server with its own AWS Signature Version 4 signing and no dependency. It puts, gets and checks one object per key. The key is encoded the way the AWS CLI encodes it, so a name with spaces or accents has the same signature. The path of the endpoint (for example https://host/s3) stays in front of the bucket in the URL and in the signed path. A network fault, a 500 and a 503 are tried again, three times in all. Any other refusal names the method, the key and the status.
The config is { endpoint, region, bucket, accessKeyId, secretAccessKey, prefix? }. The test test/server-s3.test.mjs holds two signatures that the AWS CLI 2.24.14 made and a test on a real S3 server, which runs when DESK_TEST_S3_ENDPOINT, DESK_TEST_S3_BUCKET, DESK_TEST_S3_ACCESS_KEY and DESK_TEST_S3_SECRET_KEY are set.
Proposals, reviews and checks (#61)
A proposal is a change that waits for review. It lands onto main with land. src/server/forge.ts answers the PR calls of Forge: createPr, pr, prForBranch, openPrs, mergedPrs, postReview, reviewThreads, existingBodies, checkRollup, mergePr and closePr. The proposal takes its number from the same count as the issues, so no two issues and proposals have one number.
In a work folder, the client makes and lands proposals with no git (prOnServer in src/agent.ts):
desk pr create --title <t> --body-file <f>checks the title and the body likedesk pr check, then proposes the last change of the folder (desk change) with the labeltype: <type>.desk pr view --pr <n>prints the proposal and its head;--jsonprints all fields.desk pr merge --pr <n>lands the proposal at the head that it shows. A merged proposal is refused.desk pr close --pr <n>closes an open proposal that will not land, for example one whose fix after a review went into a new proposal (#123). A closed proposal answersCLOSED, is not inopenPrs, and does not land; its head can get a new proposal.
Tables (migration 010_proposals.sql):
proposals: one row for each proposal.change_idis the head change, and its id is also the branch of the proposal.stateisopen,landedorclosed(migration022_proposals_closed.sql).landed_asis the change that holds the proposal onmain.labelsholds the label that the creation gave.checks: one row for each result of a check. The newest row of each name gives the state of the check.reviews:authorandbodyare kept now.review_comments: one row for each inline comment of a review.
Calls:
createPrtakeshead, the id of an indexed change. It refuses a change with no row, a change that has an open proposal, a draft, a base other thanmain, and an empty title.postReviewstores a review with the verdictcomment. It refuses a review whose commit is not the head change of the proposal.mergePrneeds the rightmerge, because it movesmain.desk landneeds the same right.- It lands the head change with
landWithChecks(src/server/code-http.ts), the function ofdesk land. - The checks with
stage: "land"run first. The other checks are queued after the landing and recorded for the bisect (docs/runs.md). - While a land check runs, the merge gives an error that names the runs. Merge again when they end.
- A failed land check or a conflict gives an error that names the runs or the paths. The proposal stays open, and
maindoes not move. - A merge of a landed proposal gives its link again.
- It lands the head change with
- A new proposal runs the checks of the repository on the tree of its head change (
src/server/proposal-checks.ts): each entry ofvalidation, the gate checks too, whose paths match a file that the proposal changes againstmain. Each is recorded aspendingwith the link of its run, and the end of the run recordssuccessorfailure. A run that ends with an error runs again, until 3 errors (CHECK_ERRORS). The tableproposal_checks(migration026_proposal_checks.sql) names the run of each check.maindoes not move. POST /v1/proposals/<owner>/<repo>/<n>/checkswith{"name", "state", "url"}records one result of a check.stateispending,successorfailure. The route needs therunright, so only a runner records a result, and answers 201. A missing proposal answers 400.
The server has no call that closes a proposal without a merge. fileAt reads a file of a change from the code store.
Releases (#63)
A release names a change by a tag, with notes and files (src/server/releases.ts). The bytes of a file are a blob in the object store of the repository: a client uploads each file with PUT /v1/code/<owner>/<repo>/objects/blob/<id>, then names the blobs in the release. The server checks that the change and each blob are there. The mirror pushes the tag of a release to the copy (refs/tags/<tag>) when the change has a git commit there, with no force: a tag that points elsewhere on the remote stays, and the log names the error.
| Route | Right | Answer |
|---|---|---|
GET /v1/releases/<owner>/<repo> |
read | {"releases": [...]}, the newest first, at most 100 |
GET /v1/releases/<owner>/<repo>/<tag> |
read | the release, or 404 |
POST /v1/releases/<owner>/<repo> with {"tag", "change", "notes", "files": [{"name", "blob"}]} |
write | 201 and the release; 409 for a tag that exists, also for two writers of one tag at once; 400 for a change or a blob that the server lacks |
A release is {"tag", "change", "notes", "author", "createdAt", "files": [{"name", "blob"}]}, with the files in byte order of their names. A file comes back with GET /v1/code/<owner>/<repo>/objects/blob/<blob>. A tag is 1 to 128 letters, digits, ., _, + or -; it is also a git ref, so it starts with a letter, a digit or _, holds no .., and does not end with . or .lock. A file name is 1 to 200 of the same characters; a release has at most 100 files.
Tables (migrations 017_releases.sql and 018_release_files_no_size.sql):
releases: one row for each release;(repo_id, tag)is unique, and(repo_id, change_id)must be a change of the repository.release_files: the name and the blob id of each file of a release.
Metrics (#77)
The server serves GET /metrics in the text format of Prometheus on its own port, METRICS_PORT (9464 by default; 0 turns it off), on the host of the API (src/server/metrics.ts). The ingress does not route that port, because the metrics name the repositories.
| Metric | Type | What it counts |
|---|---|---|
desk_http_requests_total{route,status} |
counter | requests to the API; route is the first part after /v1 (/v1/code, /v1/runs, …) |
desk_http_request_duration_seconds{route} |
histogram | the time of a request, from 5 ms to 30 s |
desk_mirror_push_failures_total{repo} |
counter | pushes of a repository to its copy that failed |
desk_runs{repo,kind,status} |
gauge | runs that wait or run, read at each scrape |
desk_landings_total{repo} |
counter | landings on main: the last entry of the log of each repository, read at each scrape |
desk_db_up |
gauge | 1 when the database answered the queries of the scrape, 0 when not |
process_resident_memory_bytes, process_cpu_seconds_total, process_start_time_seconds |
the process |
Runbook of the alerts (#77)
munhq/k3s-gpu (charts/desk/templates/metrics.yaml) scrapes /metrics of the server on port 9464 and holds five alerts. Each links to its section here. The commands use KUBECONFIG=~/.kube/ovh-gra1.yaml.
Runbook: DeskMetricsDown
Prometheus cannot read /metrics of the server for 5 minutes, or the Service desk/desk-metrics has no target.
- Run
kubectl get pods -n desk -l app.kubernetes.io/name=desk. A pod inCrashLoopBackOffnames the fault inkubectl logs -n desk deploy/desk --previous. - If the pod runs, run
kubectl logs -n desk deploy/desk --tail=100. The first lines name the build, the object store and the queue. - If the start fails on a migration, compare the checksum error with
server/migrations. Never change a migration that a server applied; add a new one.
Runbook: DeskDatabaseDown
The server runs, and its database does not answer. desk keeps its records in the database desk of the cluster database/postgresql.
- Run
kubectl get clusters.postgresql.cnpg.io -n database postgresql. The status names a failover or a fault. - Find the primary with
kubectl get pods -n database -l 'cnpg.io/cluster=postgresql,role=primary', then runpsql -U postgres -d desk -c 'SELECT 1'in its containerpostgres. - If the database is lost, recover it from the backup:
scripts/desk-restore-test.shof munhq/k3s-gpu shows the recovery manifest. A recovery of the whole cluster (16 GB) takes about as long as its backup, which took 67 minutes on 2026-10-24.
Runbook: DeskRunsWaiting
A run is queued for 30 minutes, and no runner takes it.
- Run
kubectl get deploy -n desk | grep runner. The runner of the repository and the lane of the run (desk-runner-<repo>,desk-base-runner-<repo>,desk-deployer-<repo>) must be ready. - Read the runner log:
kubectl logs -n desk deploy/desk-runner-<repo> --tail=100. - Read the run:
desk runs view <n> --repo <owner>/<repo>, and its log withdesk runs log <n> --repo <owner>/<repo>.
Runbook: DeskMirrorFailing
Five pushes of a repository to its copy on GitHub failed in 30 minutes.
- Run
kubectl logs -n desk deploy/desk | grep 'was not pushed'. The line holds the error of git. - "rejected" or "fetch first": someone pushed to the copy. Desk does not overwrite the copy. Bring the commit into desk with the import, or remove it from the copy.
- "Permission denied (publickey)": check the deploy key of the repository (
desk-mirror-key,desk-mirror-key-<repo>) on GitHub.
Runbook: DeskApiErrors
More than 5% of the requests of the API answer 5xx for 10 minutes.
- Find the route group:
sum by (route, status) (rate(desk_http_requests_total{status=~"5.."}[10m]))in Grafana. - Read
kubectl logs -n desk deploy/desk | grep 'desk server:'. Each 500 writes its stack there.
Test the alert rules
In munhq/k3s-gpu, with promtool of the version of Prometheus on hotmun (3.13.0):
helm template desk ansible/argocd/charts/desk -n desk -f ansible/argocd/config/desk/values.yaml > /tmp/desk.yaml
python3 -c 'import yaml; d=[x for x in yaml.safe_load_all(open("/tmp/desk.yaml")) if x and x["kind"]=="PrometheusRule"][0]; yaml.safe_dump({"groups": d["spec"]["groups"]}, open("/tmp/rules.yaml","w"))'
cp ansible/argocd/charts/desk/tests/rules_test.yaml /tmp/ && cd /tmp && promtool test rules rules_test.yaml
The review of a proposal on the server (#62)
desk review --pr <n> in a work folder of the desk client, or in a git repository with store server, reviews the proposal n of the server with no git:
- The proposal comes from the Forge of the server (
pr), and its head is a change id. GET /v1/code/<owner>/<repo>/merge-base/main/<head>gives the base: the newest change of the first-parent line of the head thatmainhas.GET /v1/code/<owner>/<repo>/diff/<base>/<head>?context=<n>gives the unified diff (src/server/tree-diff.ts).git applyapplies it, and its counts of added and deleted lines equalgit diff --numstat. git can choose another diff of the same size, so the hunks can differ.src/review-server.tswrites the files of the base and of the head into two temporary folders withexportChangeof the client. A symlink becomes a text file that holds its target, so a read never follows a link of the proposal to a file of the machine.- The instruction files are read at the base, the specs at the head, and the review engine reads the head folder. The command deletes the folders when it ends.
- The issues that the proposal and its changes name come from the server. The review threads come from
reviewThreadsof the Forge. - The report goes to
.desk/out/of the work folder. The client never sends.desk/out/in a change. desk post --run <dir>writes the review to the proposal withpostReviewof the Forge of the server:--aiposts every finding that the verification kept, and--as reviewposts the accepted findings with their inline comments.--as commentposts a review with no inline comment, because a proposal holds reviews and no comments. A finding that a review of the proposal holds already is not posted again.- The owner's feedback reaches the review through the issues that the proposal names (
desk feedback --issue <n>).
The copy on a git remote, and the move of a repository (#78)
With several replicas of the server, one pushes the copy of a repository at a time (mirror_leases, migration 019_mirror_leases.sql): a server takes the lease of the repository, pushes, and deletes the lease. While another server holds it, it tries again every 5 s for a minute, so a landing on any replica reaches the copy within a minute. A server that stops during a push leaves its lease, which expires after 15 minutes; then another server takes it. Each git command that talks to the remote ends after 5 minutes (REMOTE_GIT_TIMEOUT_MS), so a push ends before its lease.
The server keeps a copy of a repository on a git remote, such as GitHub (src/server/mirror.ts):
DESK_MIRRORSnames each copy:owner/repo=<remote>, several separated by commas. A remote is an https URL with no credentials (https://host/owner/repo.git), an ssh remote with a user and no password (git@github.com:owner/repo.gitorssh://git@host/owner/repo.git), or an absolute path.DESK_MIRROR_DIRis the folder of the bare repositories, on a disk that stays. The first push clones the remote there, so the commits of the imported history are there.DESK_MIRROR_TOKENis a token that may push to the https remotes. git gets it as the header of HTTP basic auth with the userx-access-token, throughGIT_CONFIG_*variables of its environment: the token is in no URL, no file of the disk and no command line.DESK_MIRROR_SSH_KEYis the file of a private key that may push to the ssh remotes, such as a deploy key of GitHub with write access to the one repository. GitHub takes one deploy key on one repository only, soDESK_MIRROR_SSH_KEYS(owner/repo=<file>, separated by commas) names the key of each repository;DESK_MIRROR_SSH_KEYis the key of the others.DESK_MIRROR_KNOWN_HOSTSis the known-hosts file of those remotes, and the two go together. ssh uses only that key (IdentitiesOnly), asks nothing (BatchMode), and accepts a host only with a key of that file (StrictHostKeyChecking=yes). An ssh remote with no key is not pushed, and the server log says so.DESK_MIRRORSwith no object store stops the server at its start.- After each landing, and every minute, the server exports
mainwithexportGitand pushes it. The pushes of one repository run one at a time. - A remote that has commits that desk does not have gets a merge commit: its parents are the head that desk exports and the commit of the remote, and its tree is the tree of desk's main. The remote keeps its commits, main keeps desk's code, and the server log names the commit. The server never force-pushes, and no other path writes to the remote. Code that only the remote's commits hold stays in that history and is not on main; a person who needs it lands it on desk as a change.
- The copy of a repository on GitHub uses a deploy key (the owner's decision on #78, 2026-10-22): the
SSHKeygenerator of External Secrets makes the key one time in the cluster, its private part stays in a Secret of the server, and its public part is a deploy key with write access on the one repository.
The move of a repository onto the server, part 1, the copy of the data. An admin (see "Repositories through the API") runs it from any machine where git and gh work, with the token of the admin in DESK_TOKEN:
- Create the repository:
desk repo create <server> <owner/repo>. The admin gets read, write and merge on it. - Give the rights:
desk repo grant <server> <owner/repo> <identity> <read|write|merge|run>, for exampledesk-uiread and the agents write and merge. - Import its history and its issues:
desk repo import <server> <owner/repo> --git git@github.com:<owner>/<repo>.git --issues. A second run imports only the new commits and adds what changed in the issues. When the server history has landings that the source does not have, the import fails with "the chain does not continue from the head of main";--replacethen removes the code history of the repository (its refs, log, changes and git commits, and the checks, images and reverts of those landings) and imports it again. The issues, comments, decisions, grants and runs stay, and a repository with a proposal is refused. - Compare
GET /v1/code/<owner>/<repo>/logwithgit log --first-parent mainof the remote. They must show the same messages in the same order.
The admin program in the pod does the same steps, for a server with no admin yet: repo add, grant, import-git <owner/repo> <git url> main (it reads a private repository on GitHub over ssh with the deploy key of its copy, DESK_MIRROR_SSH_KEYS or DESK_MIRROR_SSH_KEY), and import-issues <owner/repo> where gh is signed in, with the DATABASE_URL of the server.
Part 2, the switch:
- Add the repository to
DESK_MIRRORSwith its ssh remote, and add its deploy key on GitHub. - Run the import of the issues once more (
desk repo import <server> <owner/repo> --issues), so that the issues of the last hours are on the server. - Land the change that sets
"store": "server"and theserverblock in.desk/config.json. From then on the agents work in work folders (desk checkout), and GitHub holds only the copy.
Repositories through the API (#118)
An admin creates a repository, gives the rights and imports a git history and the issues through the API, so that a repository moves with no write into the pod (src/server/repo-http.ts, the client src/repo.ts). The admins are the identities that DESK_ADMINS names, separated by commas; the config of the deployment sets it, and any other identity gets 403.
| Route | Body | Answer |
|---|---|---|
POST /v1/repos |
{"slug"} |
201 {"slug", "created": true}, or 200 when it exists. The caller gets read, write and merge. |
POST /v1/repos/<owner>/<repo>/grants |
{"identity", "right"} with read, write, merge or run |
201 |
PUT /v1/repos/<owner>/<repo>/import/git?ref=<ref>&replace=1 |
a git bundle | 202 with the state of the import |
GET /v1/repos/<owner>/<repo>/imports/<id> |
the state: running, done with the counts of importGit, or failed with the error |
|
POST /v1/repos/<owner>/<repo>/import/issues |
{"issues", "comments", "truncated"} |
200 with the counts of importIssueRows |
The git import:
desk repo import --gitclones the source bare on the machine of the admin and sends a bundle of the one ref (defaultmain). The server needs no login of the source, so a private repository moves the same as a public one.- The server claims the job first: the table
imports(migration020_imports.sql) holds one running import for each repository, and a second one gets 409 before its upload. A declared body over 2 GiB gets 413 before the claim; a streamed body over it ends the job as failed. - The job writes a heartbeat every 15 s from its claim on, also during the upload. The server writes the bundle into its temporary folder, answers 202, and imports it with
importGitSource(git cloneof the bundle, thenimportGit) in the same process, with its progress. The client reads the state from any replica every 2 s. - A server that stops during an import leaves a job with no heartbeat. After 2 minutes the next read or import ends it as failed ("lost"), and a job that was ended so does not write its state again. The import converts only what is missing, so the same command repairs it.
The issue import reads the issues and their comments of the same repository on GitHub with gh on the machine of the admin (--issues) and sends them. The server keeps the numbers, stores a comment once by its URL on the forge, and skips the comments of PRs. test/server-repo.test.mjs covers the routes, the job, the limits and the CLI.
Orgs, members and the device login (#130)
An org is the owner part of the slugs: acme in acme/example-app. A member of an org has read, write and merge on every repository of the org, also on one made after the membership. An admin of the org also adds and removes members. The right run comes only from a grant, so a person never records a check result (src/server/org-http.ts, migration 028_orgs.sql).
- Tables:
orgs (name),org_members (org, identity_id, role member|admin, added_by)andidentity_tokens (identity_id, token_hash, label, revoked_at).identities.emaillinks an identity to the UI account with that email. - Rights: the view
repo_rightsjoins the grants with the rights of the members.can(),GET /v1/repos, the decisions page and the Config view read it. A trigger onreposmakes the org of each new repository. - Tokens:
authenticate()acceptsidentities.token_hash(an agent,identity add) and each token ofidentity_tokensthat is not revoked. A device login adds one token for each machine, so a new machine does not end the login of another one.
| Route | Who | Answer |
|---|---|---|
GET /v1/orgs |
any identity | {"orgs": [{"name", "role"}], "serverAdmin"}. A server admin sees every org as admin. |
POST /v1/orgs {"name"} |
a server admin | 201 {"name", "created": true}, or 200 when it exists |
GET /v1/orgs/<org>/members |
a member, a server admin | {"org", "members": [{"identity", "kind", "email", "role", "addedBy", "since"}]} |
PUT /v1/orgs/<org>/members {"email" or "identity", "role"} |
an admin of the org, a server admin | the member and created. An email with no identity makes one, kind user, named after the part before the @ (simona, or simona-2 when that name is taken). An identity by name, and both names (which link the email to that identity): a server admin only. |
DELETE /v1/orgs/<org>/members/<identity> |
an admin of the org, a server admin | {"org", "removed"} |
POST /v1/identities/<name>/rename {"to"} |
a server admin | {"identity", "renamed"}. The records keep the name of their author as text, so an identity that wrote a change, an issue, a comment, a proposal, a review or a decision keeps its name. |
POST /v1/identities/<name>/revoke |
a server admin | {"identity", "revoked": true}. Its tokens and keys stop, and it leaves its orgs. |
GET /v1/people/<email> |
desk-ui only |
{"email", "identity", "orgs", "serverAdmin"} |
POST /v1/people/token {"email", "label"} |
desk-ui only |
201 {"email", "identity", "token"}: a new token for the person, made after a device login |
The UI calls the server for the signed-in person: it sends its own token and the email in the header x-desk-person, and authenticate() answers the identity of that person on every route, so the call has the rights of that person (#133). Only desk-ui may name a person; another identity that sends the header gets 401. A person that the UI names for the first time gets an identity.
POST /v1/code/<owner>/<repo>/edit with {"message", "files": {"<path>": "<text>" | null}, "base"?, "intent"?} needs the right merge. It makes a change of those files on main (or on base), with the caller as its author, and lands it as desk land does, with the gate and the checks. The MCP endpoint of the UI lands code this way, with no work folder.
The device login (desk login, RFC 8628):
desk loginasks the UI for a code (POST /api/auth/device/code, client iddesk-cli), prints it and opens/device?user_code=<code>in the browser.- The person signs in to the UI and approves the code. The page reads the code first (
GET /api/auth/device), so the code belongs to that session. desk loginpollsPOST /api/auth/device/tokenand gets a session token of the UI.POST /api/cli/tokenwith that session gives the token of the desk server (POST /v1/people/token), and the session ends.desk loginchecks the token withGET /v1/reposand stores it as before.
The terminal half comes from @munhq/mcp-kit/cli (#132): deviceLogin runs steps 1 to 3 and saveSecret keeps the token. That package is on GitHub Packages, and desk installs from GitHub with no token, so desk carries its compiled files in src/vendor/mcp-kit (the version is in VERSION), and the build copies them into dist/vendor. To take a new version, run NODE_AUTH_TOKEN=<a GitHub token that reads packages> npm run vendor -- <version>, then build and test.
desk org list, desk org members <org>, desk org add <org> <email> [member|admin], desk org add <org> --identity <name> [--email <email>] [member|admin] desk org remove <org> <identity>, desk org rename-identity <name> <new name> and desk org revoke-identity <name> call these routes with the token of the login (src/org.ts). test/server-orgs.test.mjs covers the rights, the roles, the UI header and the tokens; test/login.test.mjs the device login of the CLI; ui/e2e/access.spec.mjs the whole path in a browser.
The claims of the issues (#119)
The table issue_claims (migration 021_issue_claims.sql) holds one claim for each issue: the identity, the holder (the id of a work folder, or a name), the start and the expiry. The Forge of the server answers claimIssue(n, holder, hours), releaseIssue(n, holder) and issueClaims(holder) (rights write, write and read). One INSERT … ON CONFLICT … WHERE decides a race: the row is written only when the issue has no claim, the claim expired, or the claim is of the same identity and holder. A refused claim names the holder and the expiry. closeIssue removes the claim. The GitHub Forge refuses a claim and lists none. docs/state.md describes the commands and the order of desk state; test/server-claims.test.mjs covers the race, the renew, the expiry, the release and the close.
The move of a repository (#125)
desk repo move <server> <owner/repo> [--base] [--keep-actions] moves a repository from GitHub in one call (moveRepo in src/repo.ts), with the token of an admin of DESK_ADMINS in DESK_TOKEN. The server does every GitHub step with its GitHub App (src/server/github-app.ts: GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY, from Vault agent/personal/desk/github). The App, desk-hotmun of munhq, is installed on all repositories of munhq, with the rights Contents, Administration and Issues. One installation token serves the server until 5 minutes before its end. The steps, each skipped when it is done:
- Create the repository on the server and give
desk-uithe right read. POST /v1/repos/<owner>/<repo>/import/github: the server cloneshttps://github.com/<owner>/<repo>.gitwith the token of the App as a header of git and imports the history ofmainas a job (the job of #118).POST /v1/repos/<owner>/<repo>/copy: the copy is that remote, in the tablerepo_copies, and the server turns off the GitHub Actions of the repository. With--keep-actions(body{"actions": "keep"}) the Actions stay on: a public repository that publishes its npm package and its public image from GitHub keeps those workflows, and desk pushesmainand the release tags to the copy that starts them.POST /v1/repos/<owner>/<repo>/settingswithrunner(andbasewith--base): the runner pool, and the right run fordesk-runner.POST /v1/repos/<owner>/<repo>/import/github-issues: the issues and their comments, read with the App, with no pull requests.- The store switch:
.desk/config.jsongets"store": "server"and theserverblock, and keeps its other fields. A Rust repository (aCargo.tomlat the root) with noscripts/buck/SHAREDalso gets the shared Buck2 files of desk (buck/rust/sync.sh).
A repository whose main says "store": "server" already keeps its code and its issues: the move then does only steps 1, 3 and 4. test/server-move.test.mjs runs the move against a server on PGlite, a fake GitHub API and a folder as the git base, then a second time with no change.
The copies of the database and the lanes replace the lists of the deployment:
- The mirrors read
repo_copiesat each round (every minute, and after a move on this server), and push a copy of the App with the token of the App.DESK_MIRRORSadds copies with the keys of the deployment; a copy of the database wins for its repository. - A runner with no
DESK_REPOSreadsGET /v1/runner/repos?lane=runs|baseevery minute: the repositories of its lane where its identity has the right run. The repositories ofDESK_RUNNER_REPOSgetrunnerat the start of the server.
In a folder of the policy paths, the policy field "server": "https://desk.example.com" (~/.config/desk/policy.json) puts every repository on that server: the hook refuses gh issue create, gh pr create and gh repo create in a repository that is not there yet, and names desk repo move.
Deployment (#69, #76)
The image ghcr.io/munhq/desk-server runs node dist/server/main.js. .github/workflows/server-image.yml builds it after each merge to main, with the merge commit in DESK_BUILD_COMMIT, and tells Keel to roll it.
| Variable | Source on hotmun | Use |
|---|---|---|
DATABASE_URL |
the deployment | The Postgres of the records, through the pooler. Required. |
DATABASE_URL_DIRECT |
the deployment | The primary with no pooler: the migrations run here. |
DESK_BASE_URL |
the deployment, https://desk.munhq.com |
The base of the links in the answers. Required. |
PORT, HOST |
the deployment | Where the server listens; 8080 and 0.0.0.0 by default. |
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY (and S3_REGION, which defaults to us-east-1) |
Secret desk-s3 of the platform-data registry |
The object store of the code. A part of them stops the start. |
DESK_OBJECTS_PREFIX |
optional | A prefix for the keys in the bucket. |
DESK_BLOBS_DIR |
optional | A folder for the objects when no S3 store is set. |
DESK_CACHE_DIR, DESK_CACHE_MAX_BYTES |
optional | The disk tier in front of S3 (#102). |
DESK_MEMORY_CACHE_BYTES |
optional | The memory tier of the folders, 256 MiB by default. |
REDIS_URL |
the ConfigMap desk-redis, which the registry entry desk of platform-data makes |
The Dragonfly tier (#102). |
NATS_URL, DESK_NATS_STREAM, DESK_NATS_REPLICAS |
Secret desk-nats of the platform-data registry (with the login) |
The queue of the runs (#64, docs/runs.md). Without NATS_URL the queue is in the memory of the process. |
DESK_BUILD_COMMIT |
the image | /v1/health answers {"ok": true, "build": "<commit>"}. |
DESK_UI_TOKEN |
Secret desk-ui-token (Vault managed/desk/ui) |
The server keeps the identity desk-ui on this token at each start. The UI reads the server with it; grant desk-ui <owner/repo> read gives it a repository. |
DESK_ADMINS |
the values of the chart | The identities with the admin right, separated by commas (#118). |
GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY |
Vault agent/personal/desk/github, through the ExternalSecret desk-github-app |
The GitHub App of the server (#125): the copies, the import from GitHub and the end of GitHub Actions. Without it the copy and GitHub import routes answer 501. |
DESK_CLOSE_SWEEP |
optional | off stops the sweep that closes an issue whose "Done when" is fully ticked and whose landing passed (src/server/close-sweep.ts, docs/decisions.md). On by default. |
The admin commands run in the pod: kubectl exec deploy/desk -- node dist/server/admin.js <command>. An admin of DESK_ADMINS does the same with desk repo through the API, with no write into the pod (#118).
| Command | What it does |
|---|---|
repo add <owner/repo> |
Makes the repository. |
identity add <user|agent> <name> |
Makes an identity and prints its token one time. The server keeps only the hash. |
grant <name> <owner/repo> <read|write|merge> |
Gives a right. |
import-git <owner/repo> <git url or folder> [<ref>] |
Clones the source bare and imports its history into the store; a second run adds only the new commits. |
Checked on 2026-10-15 with the image on Postgres 18.4 and SeaweedFS: the import of a repository of 124 commits took 5.4 s, and an agent token read the head, the log, a folder, a file, the blame and a search over HTTP.
The Forge of the server for the decisions page (#68)
The decisions page (src/decisions.ts) reads and writes through Forge, so it runs on a server repository. serverForge takes the store of the objects (blobs) and then reads the code of main:
defaultBranchHeadgives the change thatmainpoints to.fileAt(change, path)gives the text of a file of that change, or undefined for a missing path or a folder.pathExists(path)checks a path onmain.
defaultBranch is main. pullRequests is empty: the proposals are read with pr, openPrs and mergedPrs. issueBody gives the newest body of an issue, and closeIssue sets its status to done. test/decisions-server.test.mjs answers a question on a server repository and reads the decision back.
The read tiers of the code (#102)
The code (files, folders and changes) is read through tiers in front of S3, as the storage plan of 2026-10-17 on #32 sets (src/server/tiers.ts):
| Order | Tier | Setting | Holds |
|---|---|---|---|
| 1 | Memory of the process (MemoryTier) |
DESK_MEMORY_CACHE_BYTES, default 256 MiB; 0 turns it off |
the folders (trees) read last; one folder at most an eighth of the tier |
| 2 | Local disk (DiskTier) |
DESK_CACHE_DIR; DESK_CACHE_MAX_BYTES, default 10 GiB |
every object read; past the limit, the files read least recently go until it is at 80% |
| 3 | Dragonfly or Redis (RedisTier) |
REDIS_URL; DESK_REDIS_TTL_SECONDS (7 days), DESK_REDIS_MAX_OBJECT (256 KiB), DESK_REDIS_PREFIX (desk:<bucket>/<DESK_OBJECTS_PREFIX>:, so two deployments on one Redis never share a key) |
small objects, each with a time limit, shared by all server pods |
| 4 | S3 | the S3_ settings |
every object, forever |
- A read takes the first tier with an intact copy (the hash of the bytes must match the key) and copies it into the tiers in front of it.
- A write goes to S3 first, then into the tiers, so desk confirms an object only when S3 holds it.
- A tier that fails, is full or does not answer within 500 ms (Redis) is a miss, never an error.
fetchreads the objects in groups of 64, oneMGETfor each group, and stops at its byte limit (64 MB).missingsends one pipeline for its whole list. Only the misses go on to the next tier.
The thin client of the agents (#69)
An agent works in a work folder with no git. Commit, merge and rebase run on the server, as in the design of #32 and the decision of the owner of 2026-10-17. The client only makes the objects of a change, sends the ones that the server lacks, and asks the server to land the change (src/client.ts).
A work folder holds the files of the repository and the folder .desk-work:
work.json: the server, the repository, the name of the token variable and its sealkeep name, the base change and the last change.objects/: a cache of the objects by hash. An object that is in the cache is not fetched again.index.json: the size, the time and the id of each file at the last scan. A file that did not change is not read again.
| Command | What it does |
|---|---|
desk login [<server>] [--token | --token-stdin] [--no-browser] [--file] [--name <sealkeep name>] |
Keeps the token of a person once. With no token option it runs the device login in the browser (#130). With no server it uses the login already kept, the policy, DESK_SERVER, and then https://desk.munhq.com (FLEET_SERVER in src/agent.ts), so a new machine needs no address. --token asks for a token (hidden) and --token-stdin reads it from stdin. It checks the token with GET /v1/repos, stores it in sealkeep (or, with --file or with no sealkeep, in ~/.config/desk/token, mode 0600), and writes ~/.config/desk/login.json with the server and where the token is. A token file that other users can read is refused. |
desk checkout <repo> [folder] |
After desk login: writes the files of main into ./<name> or the folder. A name with no owner is the one repository of the server that ends with it. |
desk checkout <server> <owner/repo> <folder> [--token-env NAME] [--token-secret NAME] |
Writes the files of main. The token comes from NAME (default DESK_TOKEN). --token-secret keeps the sealkeep name of the token in the work folder. |
desk status |
The files that differ from the base change. |
desk change -m <text> [--intent <n>] |
Makes a change on the base and sends the objects that the server lacks. |
desk land [--wait] |
Lands the last change. When main moved, the server merges it; a conflict prints its paths and nothing lands. --wait follows the checks, images, promotions and deploys of the landing (docs/runs.md). |
desk runs … |
The runs, the checks of main, one run and its log, and the loop of a landing (docs/runs.md). |
desk sync |
Moves the base to main. A file that only main changed is replaced. A file that both changed keeps the local version, and the version of main goes beside it as <path>.desk-main. desk change refuses while such a copy exists. |
The token needs no wrap by hand. When the token variable is empty, desk runs again under sealkeep run -e <variable>=<name> (src/token.ts). The name comes from DESK_TOKEN_SECRET (one identity for one session), then from tokenSecret of the work folder, then from tokenSecret of ~/.config/desk/policy.json. sealkeep gives the value to that process only and redacts it from the output. The hook, help, install, mcp, serve and runner never wrap.
.gitignore and .deskignore keep files out: the .gitignore of the root and of each folder (for the paths under that folder), then .deskignore of the root, which wins where both match; the syntax of .gitignore: *, **, ?, a leading /, a trailing / and !. A file that git ignores, such as a .env, a key or a build output, never goes up, also in a moved repository that has no .deskignore (found by the axon session on 2026-10-25). .desk-work and the .desk-main copies are never sent. A .desk-main copy that the agent deletes counts as merged: the file then lands as it is.
In a work folder, the commands that read GitHub are refused, and the hook refuses every git and jj call and gh issue, gh pr, gh api, gh label and gh project, with the desk command to use (docs/enforcement.md). desk issue view, list, create, edit, comment, questions and check, and desk feedback --issue, reach the server of the work folder. desk issue template prints the format with no server.
test/agent.test.mjs runs the CLI with an empty PATH (no git) against a real server on PGlite: checkout, change, land, a merge on the server, a conflict, a sync with a .desk-main copy, and the merged landing.
The objects travel in frames: the type (1 byte), the id (32 bytes), the length (4 bytes, big endian) and the bytes (src/server/frames.ts).
POST /v1/code/<owner>/<repo>/closurewith{"at": "main" | <change>, "have": [<folder ids>]}answers the change and every folder and file under its tree in one streamed body, each object once. The headersx-desk-changeandx-desk-treename the change and its tree. A folder inhaveis left out with all under it. The answer stops at the byte limit (64 MB) after a whole frame.desk checkoutis this one request; for a cut answer it asks withfetchfor what it lacks.POST /v1/code/<owner>/<repo>/fetchwith{"objects": [{"type", "id"}]}(at most 1000) answers the listed objects.POST /v1/code/<owner>/<repo>/objectswith a body of frames stores many objects in one request. The server checks every id, every folder and the author of every change before it stores any of them; when one check fails, it stores nothing. Then it writes the files and the folders to S3 32 at a time, and the change last.desk changesends its new objects in one request for each 32 MiB, so a change under 32 MiB is one request.
Measured on 2026-10-17 on this machine, with the server on a local Postgres 18 and a file store, and a repository of 776 commits imported (611 files, 5.6 MB):
| Step | Time |
|---|---|
desk checkout |
485–574 ms (the fetch alone: 124 ms for 85 trees in 7 requests and 603 files in 1 request) |
desk status, no change |
163 ms, of which about 27 ms is the start of Node |
desk change with one changed file |
479 ms, 3 objects sent |
desk land |
125 ms |
git clone of a local bare copy, for comparison |
76 ms |
git status, for comparison |
29 ms |
The client cache is not synced to disk: with one sync for each object, the checkout took about 2 s.
On hotmun, on 2026-10-17, with the objects in OVH S3 and no cache on the server: desk checkout of the same repository took 9.7 s, and desk change with one new file took 29.8 s. The change asked the server about all 700 or so objects of its tree, and the server checked each in S3, one after the other. Now:
- A change asks only about the objects that are not in its base, because the server holds every object of the base.
- The server checks and reads 32 objects at a time.
- The reads go through the tiers of #102 (the section "The read tiers of the code"): the memory of the process, the local disk and Dragonfly, in front of S3.
With the tiers live (build 94b8dada, 2026-10-17), from a machine about 70 ms from hotmun: the first checkout took 8.2 s, a warm checkout 1.9 s, and a change with one file 1.6 s. The warm checkout made 16 requests, one after the other (two for each level of folders), and the change made 6, with one request for each new object. closure and the batch upload make the checkout one request and the change four: whoami runs while the files are read, then missing, objects and changes. test/server-code.test.mjs counts the requests.