Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 4: the runner uploads the log, so the server cannot write it

86fe5bb49ddeeb89a5d58459f27a86a83f82481a · Keenan Nemetz · 17d ago · parent 9cc2f087

Phase 4: the runner uploads the log, so the server cannot write it

Streaming made the server the author of bytes the runner produced. Heartbeats
carried output chunks; the server concatenated them into a SQLite column and, at
finish, hashed the result itself with `LFS.PutBytes`. So the archived object's id was
the server's, not the writer's — the one output in the system that worked that way,
since artifacts are uploaded by the runner through put-object and pages/releases are
pushed by it. Nothing depended on it, which is why it was harmless; a signed run
record could never have bound it, which is why it had to go.

Streaming is dropped. At finish the runner uploads its log through the same
git-lfs-transfer path artifacts use, gets the object id the store computed from the
content, and names it in the finish message. The server's rule is the artifact rule:
the store is the authority on whether the bytes exist, and a claimed size that is not
the object's size is refused. Heartbeats now carry nothing but the lease.

A log is per attempt, not per run. A job that kills a runner is requeued, so one run
can have several logs, and the interesting one is usually the attempt that died:
`run_logs(run_id, attempt, oid, size)` replaces the single column, `AttemptLogs`
reads them oldest first, `/runs/<id>/log` serves the newest and `?attempt=N` picks
one, and `vole gc` treats every attempt's object as a root. Rows written before this
change still have their `log_oid`, which reads keep honouring; `Store.Log` and
`SetLogOID` are gone with the code that used them.

The cost, stated plainly: there is no live tail any more, and an attempt whose runner
was killed leaves no log at all — the attempt that hung is exactly the log somebody
wants. That is the trade: a log that can be shown to be the runner's is worth more
than one that arrives early. It also removes an unbounded text column from the queue,
which was growing into the WAL and every backup until a run finished.

Verified: TestCheckRunLogTrustsTheStore pins the new rule at the boundary (stored log
accepted, size mismatch refused, object absent refused, non-oid refused, no store
refused); TestLogsAreRecordedPerAttempt shows two attempts recorded separately,
`Get` returning the newest, both objects named for the sweep, and a finished run
refusing a log; TestHeartbeatAndFinishNeedTheLease now proves a stranger can neither
heartbeat, nor finish, nor record a log, and that a finished run carries no log text;
and the end-to-end test that runs the real binary now asserts the served log hashes
to the object id in the run row and that the queue kept no copy of it. scripts/ci.sh
green (gofmt, vet, build, markdown links, go test -race ./...).

docs/roadmap.md +9−6

@@ -340,8 +340,9 @@ update; the server ingests them into `data/runs.db` (unique on repo+ref+sha+work
340 340 so re-reading the spool never double-queues) and pins each run to the commit and the
341 341 workflow blob it came from. `vole runner` connects over SSH with the user's key — no
342 342 token — long-polls for a lease, checks the commit out, runs the steps with per-step
343 timeouts, heartbeats while it works and reports the exit code and log. A lease that
344 expires returns the job to the queue; a job that kills five runners is failed.
343 timeouts, heartbeats while it works, uploads the attempt's log to the object store
344 and reports the exit code and the log's object id. A lease that expires returns the
345 job to the queue; a job that kills five runners is failed.
345 346
346 347 ✅ **Slice 3 — checks on the gate.** A protected branch now needs green checks for the
347 348 approved head as well as approvals (`vole acl checks <repo> on|off`, default on; a
@@ -356,7 +357,7 @@ Remaining:
356 357 - Runner protocol: `vole runner` registers, long-polls for work, leases a job, heartbeats;
357 358 an expired lease requeues the job
358 359 - Job execution: checkout by exact SHA from the server, sequential shell steps, per-step
359 timeout, streaming logs
360 timeout, logs uploaded to the content-addressed store when the attempt ends
360 361 - Run records pin SHA + workflow file hash + toolchain env; signed with the instance key
361 362 - Statuses surfaced on CRs and satisfying required-check gating from Phase 3
362 363 - Log and artifact storage onto the content-addressed store (shared with Phase 5).
@@ -398,9 +399,10 @@ pointer is accepted with a warning naming `git lfs track "*.ext"` and
398 399 bypass is about review, not about whether the repository works. Deleting a broken
399 400 pointer remains possible, or there would be no way out of the mistake.
400 401
401 ✅ **Slice 4 — CI logs on the same store, and a sweep.** A finished run's log becomes a
402 content-addressed object in the same write that records the status, is served as
403 `text/plain` at `/r/<repo>/runs/<id>/log`, and is linked from the repository page.
402 ✅ **Slice 4 — CI logs on the same store, and a sweep.** A finished attempt's log is a
403 content-addressed object the **runner** uploaded and named (see the decision log:
404 logs are not streamed), stored per attempt, served as `text/plain` at
405 `/r/<repo>/runs/<id>/log?attempt=N`, and linked from the repository page.
404 406 `vole gc [--dry-run] [--grace]` marks the object ids named by pointers in every tree
405 407 reachable from every ref in every repository — plus archived logs, which no ref reaches
406 408 — and removes the rest. Anything younger than the grace period (default 1h) is kept,
@@ -830,6 +832,7 @@ Filled in as phases land. Append; don't rewrite.
830 832 | 2026-10-23 | The author of a metadata write is the account the key resolves to, asked with `vole-whoami`; `--author` is gone | `git config user.name` is a name somebody typed for commits: "Keenan Nemetz" sanitises to `keenan-nemetz`, which is not an account, so every issue filed from a normal machine was attributed to nobody and nothing complained. The instance is the only place that maps a key to an account, and it is one SSH verb away on the connection already open. A fallback exists for `--no-push` with the instance unreachable, and it warns loudly rather than guessing silently |
831 833 | 2026-10-23 | A remote given as an SSH URL is fetched like a named one | Skipping the fetch for a URL is what made `--remote ssh://…` write on a branch that had never been updated: the first write in a fresh repository succeeded (nothing to fast-forward from) and every later one failed as a "lost race" that never happened, re-applying on the same stale tip and reporting "already exists". `git init` plus a URL is now the real no-clone filing path |
832 834 | 2026-10-23 | The instance tells the client where it is served (`public_url`), so a command can print the page it wrote | A command that answers with an id leaves the reader to assemble a URL, and the instance is the only party that knows its own address behind a proxy. Optional: unset means ids only, which is what it printed before |
835 | 2026-10-23 | A run log is uploaded by the runner, one object per attempt; nothing is streamed | Streaming made the server the author of bytes the runner produced: heartbeats shipped chunks, the server concatenated them in a SQLite column and hashed the result, so the archived object's id was the server's, not the writer's — and a signed record could never bind it. A log is now uploaded through the same put-object path as an artifact, and the store is the authority on whether the bytes exist. The cost is a live tail: nothing is visible until the attempt ends, and an attempt that is killed leaves no log at all (the attempt that hung is exactly the log somebody wants). Accepted, because a log that can be shown to be the runner's is worth more than one that arrives early |
833 836 | 2026-10-23 | A review comment anchors on the blob of the content reviewed, and the page decides whether it is stale | A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse — any unrelated change to the branch would invalidate every anchor. The blob is content-addressed, so a comment stays glued to exactly what was read, current for as long as the file is unchanged, and an anchor that lies can only make itself stale. It also means nothing has to be checked at the push boundary: the page resolves the blob and compares, so a later head never retroactively rejects an old comment |
834 837 | 2026-10-23 | A review comment is an issue-comment file inside the request, not a new format | `requests/<ulid>/comments/<ulid>.md` is parsed by the same code as `issues/<ulid>/comments/<ulid>.md` (`issues.ParseComment`), so the thread inherited the validation, the author-edits-own-words rule and the markdown boundary instead of reimplementing them. A request's own file stays outside the ownership rule for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit |
835 838 | 2026-10-23 | "Would this merge?" is answered in memory and surfaced, never gated | `merge-tree --write-tree` merges without a worktree or an index, so the cost is one git process on a page view; gating on it would be wrong because the base can move after an approval, and the roadmap asked for detection before the push rather than a second refusal. The exit status alone is ambiguous — 1 is both "conflicts" and "bad revision" — so the answer is believed only when the output starts with a tree id, and silence otherwise |