50 descendants and 50 ancestors of a3475428ac357c63
|
2026-08-26
| ||
| 13:37 | Accepted. Clicking through found what testing the parts did not. check-in: 111491a3a9 user: dmestas tags: trunk | |
| 13:37 |
ops: the path actually requested, and two broken links
Recent renders showed the route pattern, so a dozen rows all read GET /r/{repo}/timeline and none could say which repository was slow. The URL is now recorded and shown, with the route beside it as the link to its drilldown. Two bugs found by clicking through rather than by testing the parts: queryesc double-encoded every URL it touched, because html/template already escapes a value in query context. The routes panel linked to its own drilldown with %252F for a slash, so every link 500'd. Six call sites, all wrong; the function is gone. The days control I added with the trend forwarded an empty parameter, and a numeric field with a default takes it when the key is MISSING and refuses when it is present and empty. Routes panel gains a filter and a sort; renders gain a deeper-history page reading the store rather than the ring. closed check-in: 2d033f106e user: dmestas tags: ops-paths | |
| 13:35 |
landings: mark the two-verb remark path as blocked on zit#149
Filed upstream: landing comment sets G and I to the record root, so it cannot aim a reply. The forum-reply route stays until that lands; quarry ticket 6f4c8579f0b5 tracks collapsing the fork. check-in: b459791bbd user: dmestas tags: trunk | |
| 13:28 |
footer: shed the timing breakdown before it overlaps the links
At phone width the brand and version already retire, but the timing string alone still outran the viewport — and being flex:none it printed over the footer links rather than giving way. The per-store breakdown now retires at the same step the brand does; the total, which is what the footer is for, survives. The collapse test pinned the whole media rule, so adding a part to a step read as a regression. It now matches the breakpoint and the parts within it, and asserts the total is never itself hidden. check-in: d7ff96b534 user: dmestas tags: trunk | |
| 13:13 |
landings: keep the reply badge and the state mark in one grid cell
t-row is a three-column grid. The reply badge made the state mark a fourth child, which wrapped to an implicit row under the glyph — the accepted pill dropped onto a line of its own. Both now sit in a t-tail cell, so neither can displace the other. check-in: f876bfc649 user: dmestas tags: trunk | |
| 13:09 |
landings: forum niceties — reply count, threading, per-post reply
The landing list carries a reply badge, the same .t-conv/#i-comment the ticket list uses; the count excludes state transitions, which are events rather than things anyone said. The discussion renders as the thread it is: the forum's own forumPostRow, participant spectrum, hue edge, indentation and 'replying to' attribution. The threading walk moved out of the forum handler into threadShape so the two pages cannot drift. A remark aimed at a post goes out via 'zit forum reply' — a landing thread is a forum thread of the same artifact kind, and that is the one verb that can point the I-card at something other than the opener. The target is checked against the record's own thread first. Merges the superseded/CI work that landed on trunk meanwhile. check-in: 5815a2a7d8 user: dmestas tags: trunk | |
| 12:58 | Accepted. Ordering by the DAG, one current landing per branch, and a verdict that was already an artifact. check-in: db0ed60824 user: dmestas tags: trunk | |
| 12:56 |
A landing's CI verdict: the branch tip's tag, nothing new minted
The ticket asked to decide the shape — forum reply or tag — before wiring the display. Neither is needed: a verdict is already a tag on a check-in, quarry already folds and reads them, and a landing proposes a branch whose tip is a check-in. Better on its own terms too. The verdict stays attached to what was actually tested rather than to a discussion record, keeps the zit#127 invariant with no new vocabulary, and a retargeted or reopened landing inherits the right answer without anyone copying it across. Read at render, never stored, so it cannot go stale against the branch it describes. No run leaves it empty, which reads as 'nothing has run' rather than as bad news. closed check-in: 1305bb9374 user: dmestas tags: landings-batch | |
| 12:53 |
One open landing per branch: refuse the second, and agree which wins
zit#127 left this as 'probably yes, refused by name on the second' and neither side enforced it, so two threads naming one branch produced two open landings with nothing saying which accept should honour. Both halves, because refusing is not enough: two peers can each open one in separate clones and both are valid artifacts when they meet. landing.open refuses by name; the fold picks the newest root as current and marks the rest, recomputed each fold so a record revives if the one that displaced it is settled. Settled landings never take part — a later proposal for the same branch is a new proposal, not a correction of a finished one. Nothing is deleted; a record somebody argued in is not noise. check-in: c2930a1351 user: dmestas tags: landings-batch | |
| 12:48 |
A landing's commits in ancestry order, not date order
The page listed the branch through the timeline, which orders by (date DESC, hash DESC). Two check-ins in the same second tie on date and then order by hash, which has nothing to do with which is the parent. Commits land back-to-back here routinely. zit hit this in landing show and its fix is the one to inherit: the verb owns no ancestry ordering of its own. A recursive walk from the branch tip, ordered by longest path back, stopping where the branch diverges from its target. The test commits three times in one second with hashes whose order is the reverse of ancestry — the old sort gets it exactly backwards and looks plausible doing it. check-in: 21fde3240d user: dmestas tags: landings-batch | |
| 12:44 | Accepted. A revision cannot go stale; a rendered row can. check-in: 105cfb9c18 user: dmestas tags: trunk | |
| 12:43 |
Timeline: a revision signal, not rendered rows
The stream carried the filter it was opened with, so every later tick repainted the reader's page with a view they had left. That is not a bug that gets fixed once — it shipped, was fixed this morning, and would come back the next time a default moved, because the shape makes it possible. The fleet page was rebuilt around a revision number for this reason. The timeline now follows: the signal says THAT the repository changed, the client re-requests the listing and sends its own controls with it. Nothing can capture a filter and outlive it. A fragment is delivered as SSE but is a render, so it is marked and still measured — otherwise the refresh path would have been the one route the ops panel could not see. closed check-in: 0f1c202c58 user: dmestas tags: tl-rev | |
| 12:41 |
A settled landing diffs against its fork, not the moving tip
An accepted landing showed an empty Changes section. Not an error — the right answer to a question nobody was asking. While a landing is OPEN the useful base is the target's current tip: what would merging this change now. ForkPoint's comment says as much and calls the distinction deliberate. But once the record is SETTLED the branch is already contained in the target, so that question has a degenerate answer, and the only reading left is historical: what did this branch do. That is the fork point. Base is chosen by state now. On the record that prompted this, the diff goes from 0 files to 12. Second defect in the same block: the else branch says it exists so an empty Changes section never reads as 'no changes', but it only fires when the invoke FAILS. A call that succeeded and found nothing fell through to the success path and rendered exactly that blank. It now says so, naming both check-ins. check-in: 469dbab422 user: dmestas tags: trunk | |
| 11:24 | Accepted. The link was in the DAG the whole time, and the history is finally being read. check-in: 23cec0f0fd user: dmestas tags: trunk | |
| 11:24 |
ops: read the history back — a route across days
The store recorded renders and nothing read them. The panels are one process's memory, so the page could say how fast a route is now and never whether it is worse than it was — which is the question after a deploy. The drilldown gains a per-day table from the store: p50, p95, max. Nearest-rank percentiles, matching how the snapshot computes them, so two numbers on one page both called p95 agree. Empty for no traffic and empty for history-off are different answers and the page gives the right one. closed check-in: a0fa952495 user: dmestas tags: landing-merge | |
| 11:19 |
Link a landing to the check-in it produced
settled_by names the reply that decided the state, not the commit, so this was assumed to need an upstream change. It does not: a merge check-in lists its primary parent first and the branches it merged after, so ord > 0 is the branch tip that landed, and the landing knows which branch it proposed. Structural, not a text or time heuristic. Resolves 18 of 18 accepted landings in this store. Bounded by when the landing OPENED, not when it was accepted: the merge lands before the artifact recording it is written. The landing page says what it landed as; a merge row on the timeline names the proposal and who approved it. Decorated, not collapsed — a merge is a real DAG node and hiding it would leave the graph drawing edges to a row the timeline does not show. check-in: 06475f5aee user: dmestas tags: landing-merge | |
| 10:05 | Accepted. A decision record and a discussion thread are different things, and the timeline now says which is which. check-in: 349027e507 user: dmestas tags: trunk | |
| 10:02 |
Timeline: landings are landings, not forum posts
Landings are built on forum artifacts, so the forum trigger filed every comment and verdict as kind 'f' with a 'Forum: ' prefix — rows reading 'Forum: zit-landing-state: accepted Accepted. The forge now tells y'. quarry had already decided these are not forum content; the timeline was the surface that missed it. Verdicts are kind 'l' and appear in a mixed timeline because they change the target branch. Comments are 'lc' and appear only under the new y=l. Both link to the landing rather than the post. Classified in the fold, not the trigger: the trigger fires before the landing row exists, since the root post is what it is derived from. Backfilled for stores folded before this. closed check-in: 5d398dd22e user: dmestas tags: landing-timeline | |
| 09:37 | Accepted. The panel found something real, and the two things it could not say about itself are now things it says. check-in: 13cb36d2e5 user: dmestas tags: trunk | |
|
2026-08-25
| ||
| 22:22 |
ops: name the untimed remainder instead of leaving it to subtraction
SQL has no timing category of its own and cannot easily get one: the sqlite driver builds its connector through an unexported constructor, so there is nothing to wrap. The panel's honest answer is subtraction, and subtraction the reader does in their head is not an answer. 'rest' is now a column, the lede says what the ops-minus-zit gap contains and why SQL is not split out. closed check-in: 0d65af656c user: dmestas tags: fleet-census | |
| 22:19 |
Analytics: say whether history is being kept, and stop keeping it for ever
The live panels read in-memory rings, so a forge whose store failed to open looked exactly like a healthy one. Sink.Err() existed and nothing asked it. The page now distinguishes three states: off, broken, and recording to a named path. Retention was 'append for ever', which is a default nobody was asked about. --analytics-keep sets the window, 14d by default, 0 keeps everything. Pruned at startup and daily. check-in: e2b74749b4 user: dmestas tags: fleet-census | |
| 22:15 |
Forum read-state: break the same-second tie, and prove it end to end
Marks were stored second-granular like index post dates, so opening a thread in the same second its newest post landed left the thread the reader was looking at unread. Marks now carry a fractional part, which makes the two orderable: a post from the view's own second was in the page and reads as seen; any later second re-brightens. Adds a harness test that walks the whole loop, including the part worth pinning — forum.get does not mark read, only forum.read does. check-in: c7bf7c08f0 user: dmestas tags: trunk | |
| 22:13 |
Fleet home: stop paying for the numbers a row does not show
The fleet listing called Counts, the per-section census the repository nav needs: eight aggregates per store, of which a fleet row displays two. A fold invalidates the memo, so during indexing every row went cold at once. Cold, on tcl (668MB, 42k check-ins): 230ms of census per repository against 80ms for the two numbers. Contributors now reads the maintained contributor table rather than a DISTINCT scan over every event — verified identical across all 25 demo indexes. check-in: c5d8786875 user: dmestas tags: fleet-census | |
| 22:10 |
Forum list: mute what you have read, not what is old
Dimming keyed on a 30-day window meant a quiet forum rendered almost entirely grey. Read-state replaces it: forum_read stores when an account last viewed a thread, and a thread is read only while its newest post is no newer than that view — so a reply re-brightens it with no flag to clear. Anonymous readers have no read-state and see every row at full ink. Marking read is its own op, not a side effect of forum.get, so crawlers and API clients cannot spend a reader's unread state. Titles now take the accent colour on hover (gated on hover: hover) and on :active, so touch keeps the affordance. check-in: c8a93fda64 user: dmestas tags: trunk | |
| 22:08 |
Name the per-machine config .env.local, and ship an example
dev.local.mk said nothing about what it held, so the only way to learn what `make dev` needed was to read someone's copy — and there was no copy to read, because it is not committed. .env.local now holds one named key per setting instead of a single 1,697-character DEV_ARGS line, and the Makefile composes the flags. A key left empty is omitted from the command line rather than passed as an empty string, which the server would read as a deliberate value. Corpus repositories are named once each and their paths derived, rather than --repo name=path repeated twenty-three times. .env.example is committed and documents every key, including which two are secrets and which of --data and --oltp survives a rebuild. dev.local.mk is still included, so an existing checkout keeps working, and both names are ignored. Verified equivalent: the old file and the new one produce the same 8 flags and the same 23 repositories. `make dev-args` prints the composed flags without starting anything. check-in: 6be29eb1b4 user: dmestas tags: trunk | |
| 22:02 | Accepted. The default belongs to the timeline, not to the handler that knew it first. check-in: 025d48b290 user: dmestas tags: trunk | |
| 22:02 |
Timeline stream: keep the reader's filter
The check-ins default lived only in the page handler, so a reader on the default timeline had an empty query string — and the live stream opened with it asked for no filter at all. The first tick after a landing replaced their check-ins with everything. Both the page and the stream now resolve through timelineQuery. closed check-in: 550f9601a2 user: dmestas tags: tl-filter | |
| 21:58 |
Remove g1.json, g2.html, g3.json — committed debugging scratch
Three files written in the same minute on 15 Aug and committed by accident. g1.json is a wiki API response for a page titled 'evil/traversal', g2.html the rendered edit page for it, g3.json a failed call that returned 'no such endpoint'. curl -o output, named g1 through g3, left in the repository root where they read as though they belong to the project. Nothing references them: no Go file, no Makefile, no Dockerfile. They shipped with every clone and told a reader nothing except that someone was probing path traversal in wiki page names that evening. check-in: 2946cb4429 user: dmestas tags: trunk | |
| 21:53 | Accepted. It can now say which repo and which calls — which is the difference between finding a slow route and fixing one. check-in: 7059271e59 user: dmestas tags: trunk | |
| 21:53 | Accepted. A connection is not a render. check-in: 242e2d8c1c user: dmestas tags: trunk | |
| 21:49 |
ops: record which repository a render was for
Repo was read with req.PathValue in the outermost middleware, where the router has not set it — path values live on the request the mux passes DOWN, a copy that middleware never sees. Same shape as the route-pattern bug, and it went unnoticed longer because an empty string looks like 'no repository' rather than like a failure. Every render recorded until now carried an empty repo: 61 of 61 rows in the analytics store, on a fleet where repository size is the dominant cost of almost everything. The drilldown's by-repository table showed a single '(none)' row, which is what surfaced it. Read from the path instead, unescaping as the router would. closed check-in: 059fe0bdde user: dmestas tags: ops-drill | |
| 21:46 |
ops: a door from /fleet, and a test that it stays
/fleet/ops linked OUT to /fleet seven times and nothing anywhere linked IN. The page was live, gated, and reachable only by typing the URL. Second surface to ship that way after the notes feed, so it gets a test rather than a promise to remember — both directions, so an admin is never stranded on either. The link goes in the summary strip that already carries the fleet's counts and the error badge, which is where somebody reading health already is. /fleet answers 'are my repositories healthy'; /fleet/ops answers 'is the forge healthy'. check-in: 3314daeff6 user: dmestas tags: ops-drill | |
| 21:45 |
ops: open a route up — which repository, and which store calls
The panel finds a slow route; this says why. Two things the roll-up cannot show. WHICH REPOSITORY. A route pattern folds pikchr and tcl into one line, and repository size is the dominant cost on this fleet — GET /r/{repo} at 420ms could be either, and the two differ by orders of magnitude. WHICH CALLS. Each recent request is listed with the store calls it made, in the order it made them, and what they cost together. A request with no calls listed spent its time somewhere else, which is itself the answer when the store is not to blame. Reads the same rings the panels read, so a request that has aged out simply is not there. That is the honest limit of a snapshot: this answers what a slow request just did, not what happened yesterday. check-in: bb6ec6b896 user: dmestas tags: ops-drill | |
| 21:43 |
Pin the derived/truth split so it cannot drift back
--data is derived and disposable; the OLTP is truth and is not. That is only worth stating if something checks it. Wiping the derived directory must keep accounts and capability grants, and the converse test keeps the old interleaved layout honest: without the flag the same wipe destroys them, which is exactly what the split exists to escape. Confirmed against the real binary too: wiping a 4.3M data directory leaves the forge serving 200 with its file listing intact and the index rebuilt from the stores, and the account still there. check-in: 6c2ed45467 user: dmestas tags: trunk | |
| 21:42 |
ops: tie a store call to the request that caused it
The panel could find a slow route and never say why: the render list and the subprocess list were two independent tables, so 'GET /search took 1132ms' and 'zit artifact ran 499 times' could not be connected even when one caused the other. Every request is now tagged at the outermost middleware, and every store call records the tag. Work with no request behind it — ingest rounds, warm passes, the doc indexer — records the empty string, which is itself the useful distinction: store load with nothing to blame is background, and telling the two apart is most of what the subprocess panel is for. Also fixes a silent hole of the same shape as the route-pattern bug: Repo was read with req.PathValue in the outermost middleware, where the router has not set it — the values live on the request the mux passes DOWN. Every render recorded until now carried an empty repo, 61 of 61 rows in the analytics store, which made the numbers unattributable on a fleet where repository size is the dominant cost. Read from the path instead. check-in: bc35cc0c45 user: dmestas tags: ops-drill | |
| 21:32 |
ops: a live stream is not a slow page
The fleet recorded GET /r/{repo}/timeline.sse at a p95 of 435 SECONDS. That is not a slow render, it is a connection somebody kept open for seven minutes — chat, the timeline and the ops panel all hold one for as long as the reader keeps the tab. Two consequences, the second worse than the first. The slowest-routes panel was headed by four streams, burying GET /{$} at 1198ms against a 13ms median — the one real finding on the page. And every stream crosses the render budget by design, so the first-crossing alert fired on connections that were working perfectly: the muted-channel failure that rule exists to prevent, defeated from inside the same feature. Detected from the response, not the path. A handler that streams says so in its Content-Type; a rule matching '.sse' would miss the next streaming route added under another name. Prefix-matched, so a charset does not defeat it. Found by the panel on its first look at real traffic, which is the argument for having built it. closed check-in: cf9a277e13 user: dmestas tags: ops-streams | |
| 21:27 |
Separate forge truth from derived state: --oltp
--data holds two things that behave nothing alike. Per-repository indexes, built archives and the event store are DERIVED — rebuildable from the stores (0001 5), and therefore private to one node. oltp.db is not: accounts, capability grants, webhooks, praise, mirrors, saved views and CI runs are forge-local truth (0001 8), derivable from nothing. Interleaved in one directory they could not be deployed differently, so two nodes could not have their own fast local index without also having two account databases that disagree. 256KB of the 4.5GB was the whole obstacle to running more than one node. --oltp defaults to <data>/oltp.db, so a single-node deployment is unchanged. Every command that opens the OLTP takes it. Verified: node A creates an account, node B with its own --data and the shared --oltp sees it; node B without the flag reports no accounts, which is the divergence this removes. Both nodes serve at once, each with its own index. check-in: 308751929f user: dmestas tags: trunk | |
| 21:09 | Accepted. The forge now tells you, rather than waiting to be asked. check-in: 3f238c575b user: dmestas tags: trunk | |
| 21:08 | Accepted. History, with the cgo cost taken knowingly and the wasm path verified intact. check-in: 61c32a8057 user: dmestas tags: trunk | |
| 21:08 | Accepted. The guards go in before the work they guard. check-in: 547cb92c6f user: dmestas tags: trunk | |
| 19:29 |
tests: add the budget guards, which the last commit left behind
Same mistake as before: extras was printed and then the commit ran without the add. Caught by checking the check-in rather than the worktree, which is the only check that works. closed check-in: 63ad319ef2 user: dmestas tags: ops-budget | |
| 19:29 |
ops: the budget is one number, and it notifies
500ms was a literal in a template and a constant nowhere. The panel coloured a p95 over it and nothing else knew, so the forge could show a route in red and tell no one. obs.Budget is now the single definition. The panel colours against it — sent on the wire rather than hardcoded, so the page and the threshold cannot disagree — and crossing it triggers a notification. First crossing only, on errlog's rule and for errlog's reason: the same page is slow on every request until somebody fixes it, and an alert channel that repeats itself gets muted. A muted channel is worse than none, because it reads as healthy. The decision is made under the lock, so two slow requests racing on one route cannot both report it. This is the difference between an operator watching the forge and the forge watching itself: the ops page has to be looked at; a notification arrives. check-in: fc8ef2165b user: dmestas tags: ops-budget | |
| 19:24 |
ops: history, not just a snapshot
The rings die with the process, and this fleet restarts constantly — a 4.24s render and a 546 load average both left no trace. obs.Sink had one implementation and it did nothing; this is the durable one. DuckDB because the workload is columnar: append-only, time-ordered, aggregate-queried, never point-looked-up by a page. RFC 0001 defers the lake to 'when hydration and analytics — the things columnar is for — exist', and reserves the operational surface for SQLite. This is that analytics workload, not a second operational store. Wide flat tables, no keys and no indexes, which is also what a Parquet COPY TO wants when the lake arrives. Isolated behind a build constraint, in its own package, imported only by the composition root. Both halves of the reason were measured rather than assumed: CGO_ENABLED=0 fails on go-duckdb's bindings, and GOOS=js reports 'build constraints exclude all Go files'. Leaving the import off the request path is not enough — any reachable import breaks a wasm build — so cmd/quarry has two files, one per side of the tag. Verified: GOOS=js GOARCH=wasm builds cmd/quarry clean at 76MB, without DuckDB. Buffered, never a write per request, and flushed on a timer so a quiet forge still lands rows rather than holding them in memory for days. Failure is not fatal. The first run failed on 'at' being reserved in DuckDB; the server started anyway, the panels worked, and the log said what was wrong. Analytics is derived data about a forge that works without it. Costs, measured: +462MB module cache, binary 57MB -> 104MB, cold build +10s, and CGO_ENABLED=0 no longer builds the server. closed check-in: 564f272d3c user: dmestas tags: ops-history | |
| 19:05 |
tests: the regression guards that were never added
Seven test files were written this session, reported as landed, and never committed: `zit commit` with no paths captures TRACKED files, and new files need `zit add` first. make check passed every time because they were on disk in the worktree, which proves nothing about what is in the check-in. What was missing: - search kinds, held in agreement across four places (the index list, two ops validators, the scope chips, hitOf). This is the guard that caught `doc` and again `file` the moment each was added; without it the validator silently refused a kind the chips offered. - technote edits collapsing to one timeline row, folded in three different orders, because a clone delivers artifacts in wire order. - wiki and technote search matching the CURRENT revision only, the "terrified" case. - Contributors(0) meaning every contributor rather than a silent 100. - the ops phase states, the stalled-store trap, and an empty fleet. Verified by removing them and running the suite: it stays green, which is precisely the problem — trunk has been passing on their absence. closed check-in: 24fdaa723a user: dmestas tags: search-guards | |
| 19:03 | land ops-phase check-in: 96b30ad4f5 user: dmestas tags: trunk | |
| 19:02 |
Add internal/obs, which trunk imports but did not carry
Check-in 2cacce9b01aa added obs.Default.Spawned, RecordSubprocess, RecordRender and Snapshot calls across zitcli, web and api, but the package itself was never added — so trunk did not compile at all. The files are the ones from the authoring session's tree, unmodified, tests included. Nothing here is my design; this only makes the import resolve so the tree builds again. check-in: 5229b05522 user: dmestas tags: trunk | |
| 17:37 |
ops: say what the forge is doing, not just how fast
The panels were steady-state numbers and said nothing while a fleet came up — which is the window an operator most wants to watch and the one where a forge most looks hung. A multi-repo re-ingest on this fleet was mistaken for a hang precisely because nothing said '23 of 25 stores ready' while it worked. A phase banner leads the page: starting, indexing, syncing or rest, with stores ready over stores served, artifacts this session, the slowest recent round, and the names of what is rebuilding — which, not merely that something is. Derived rather than tracked. Every store already reports Indexing, LastOK, LastCount, Total and Stalled; a phase field somewhere would be one more thing to keep true. The test ordering is the reader's priority: rebuilding outranks syncing, and a store that has never finished a round outranks both. A stalled store is DONE waiting — it needs a person, not time — so it does not hold the fleet in 'starting' forever and does not sit in the bar as pending. Ready and stalled are drawn as two segments of one track, or the bar could never fill. closed check-in: 78fbef9d30 user: dmestas tags: ops-phase | |
| 14:58 | Accepted. The forge can see itself; the rest is history storage. check-in: 2cacce9b01 user: dmestas tags: trunk | |
| 14:45 |
ops: the forge can see itself
quarry could not answer two questions about its own behaviour. A render time was printed in the page footer and discarded, so a 4.24s files page was visible only to whoever loaded it and read the footer. Subprocess load was visible nowhere: a warm loop once ran 134 concurrent `zit ls --long` against the same hash, load average 546 on 14 cores, and /fleet said "all healthy" throughout. /fleet/ops, site-admin only. Slowest routes by p95 — a mean hides the tail, and the tail is the thing worth finding. Store subprocesses rolled up per verb, with the number that actually mattered: how many run at once, and the peak, which outlives the incident. Recent renders with ops time and the zit time inside it. Instrumented at the two seams that cover everything: the ops registry, which every page's data crosses, and the outermost middleware, which is the only place the template's own cost is known — the footer's breakdown is assembled before the template runs, so a page can never report the time it spent rendering itself. Datastar with no no-script path, deliberately: an internal dashboard that cannot move while an operator watches a fleet index is worth much less than one that can, and no consumer depends on it. The op behind it is registered and versioned like any other. The rings are small and in memory. This is the snapshot; history belongs in the analytics store behind obs.Sink, which is an interface with a no-op default so the persistent implementation can be cgo without the request path importing it. closed check-in: 022966bc05 user: dmestas tags: fleet-ops | |
| 14:24 |
Ask for names when only names are used
zit ls --long adds size, permission and text/binary, and the last of those reads every listed blob. On a large store the annotated forms cost ~18ms per file where the plain two-field listing costs ~0.09ms: measured on tcl, 0.20s plain against 38s for --size and 41s for --long. The cost scales with the STORE, not the listing (filed as zit#148). Four callers read only .Name and threw the rest away: the About marker scan, a root check-in's file set, the doc index, and CI's file list. They use LsNames now, which runs the plain listing and caches it beside the annotated one. The tree page and its warm still need the columns they display, so they are unchanged; that cost is the upstream issue's to fix. check-in: 78db482057 user: dmestas tags: trunk | |
| 14:00 |
Warm the tip listing once per tip, not once per poll tick
The poll loop ran 'go cli.Ls(ctx, tip)' on EVERY tick as a bare goroutine whose result was discarded. On a store where that call takes seconds the next tick started another before the last finished, and across a fleet they compounded. Measured: 134 concurrent 'zit ls --long', all against the SAME hash, under one server, load average 546 on a 14-core machine. The listing is immutable per version, so it needs computing once per version. Now it skips when the tip has not moved and when a warm is already in flight. check-in: a3475428ac user: dmestas tags: trunk | |
| 13:50 |
One warm pass per repository at a time
warmReads and indexTree each spawn several zit subprocesses — ls, ls-under, a cat per file, an archive — and on a store tcl's size each takes seconds. They were launched as bare goroutines from every round that moved the tip, so the next round started another set before the last had finished and they stacked. Measured on the demo fleet: 43 zit children under one server, three of them the same ls against the same hash, load average 546 on 14 cores. A warm pass already running IS the warm pass. CompareAndSwap claims the slot and the winner clears it on the way out; a round that finds one in flight skips rather than piling on. check-in: 0325ba4a2c user: dmestas tags: trunk | |
| 13:04 |
Settling a landing is idempotent
Accepting an already-accepted landing wrote another state reply, and four stacked up on one record before it was noticed. Two causes. The guard read state from the INDEX, which lags the store by an ingest round, so a second press inside that window saw 'open' and passed. And zit does not refuse the write either — an already-contained branch still gets a reply saying 'accepted', with 'nothing to merge' in the body. So: fold first, re-read, then decide. A settle is deliberate and rare and can afford one ingest round to know what it is settling. And already-settled now succeeds writing nothing, because that is the end state the caller asked for; an error made a double press look broken when the landing was exactly as they wanted. Proven against a running server: three accepts in a row leave one 'accepted this' reply and no 'nothing to merge'. check-in: f102c4c7ec user: dmestas tags: trunk | |
| 12:46 | Suite green. Two fixes: page numbers centred (nav a is inline-flex, so text-align was inert) and segmented strips wrapping with gap dividers. check-in: 55fb3cfdec user: dmestas tags: trunk | |
| 12:45 |
Segmented strips wrap correctly: dividers are gaps
A border-right per item is the single-row idiom, and it breaks the moment the strip wraps — the item ending a row keeps a divider that lands against the container edge, the last item overall has none, and nothing rules between rows. Measured live at 390px the activity strip is three rows, so wrapping is the normal case. Letting the container's colour show through a 1px gap draws every divider the layout actually needs, vertical and horizontal, with no rule needing to know where a row ends. closed check-in: 76dc2b8d96 user: dmestas tags: pager-shape | |
| 12:14 |
A page number is one shape, selected or not
The glyph was always centred; the boxes were not. The current page is a span and took 0.5rem of padding with no border, every other page is an anchor and took 0.6rem plus a 1px border — 48px against 53.2px for the same digit. So the highlight changed size as the pointer moved along the row, and a number sitting still inside a box that grew around it read as though it had slid off centre. Padding and border are stated once for both now. Measured: every cell 53.2x32.7 at desktop, 37.2 wide at 390px, glyph centred in all of them. check-in: 2ae6328950 user: dmestas tags: pager-shape | |
| 12:01 | Suite green; merges clean. Three fixes: the events gate, the forum meta line, and the pager on narrow screens. check-in: 9c4f090ca6 user: dmestas tags: trunk | |
| 12:01 |
Pager fits a phone
The count is a statement about the list, not a control in it, so on a narrow screen it drops to its own line. The controls are trimmed to keep theirs: measured at 390px the row wanted 398px of a 358px container, and most of the excess was the number cells' 2rem min-width plus padding. Before, whatever spilled — usually 'older' — landed centred under the numbers beside the count, because justify-content centres each wrapped line independently and it read as though it belonged to them. closed check-in: d42e59bdef user: dmestas tags: events-public | |
| 11:43 |
Forum meta line wraps as a sentence
It was a flex container with no flex-wrap, so every bare text run between the elements — ' · started ', ' · last reply ' — was its own anonymous flex item. On a narrow screen the default nowrap shrank each item until its text wrapped INSIDE it, stacking the words in columns instead of wrapping the line once. Normal flow wraps it the way prose wraps. The name with its mark, and each date, are held together with nowrap so the break lands between them rather than through them. check-in: 968a59a2b0 user: dmestas tags: events-public | |
| 11:05 |
The event log is as public as the timeline
Every other repository page leaves visibility to the ops layer. A gateAt here made this the one page a public repository would not show an anonymous reader, while its timeline, forum and landings all did. Safe because of what is on it. The derived events restate artifacts the timeline already shows. The operational ones carry no identity at all — Counted is func(repo string, clone bool), with no user and no address anywhere in the path — so a clone event says how many, never who. check-in: 318949277b user: dmestas tags: events-public | |
|
2026-08-24
| ||
| 23:12 | land praise-ui check-in: 411e60c2d5 user: dmestas tags: trunk | |
| 23:05 | Suite green; merges clean. Already deployed — the running binary carried this while the site was down, and trunk now matches it. check-in: d8a35694f0 user: dmestas tags: trunk | |
| 22:59 |
Bind the port without waiting on every repository
The forge could not answer for minutes after starting. Register runs before ListenAndServe and probes each store with 'zit submanifest show', and the note there said it costs ~250ms — measured on small stores. On tcl it is 116 SECONDS, on sqlite 16. A fleet with either in it held the whole site down. The probe is bounded at 2s now. Past the deadline it is simply an unanswered question, which the existing false already handled: the fossil clone URL is withheld rather than offered wrongly. Registration is also timed and logged, because each repository's cost is the whole forge's downtime and a slow one should be nameable rather than suspected. closed check-in: 65e953863a user: dmestas tags: startup-latency | |
| 22:17 | merge search-current into trunk check-in: 37c9599a15 user: dmestas tags: trunk | |
| 22:17 |
search: match the current revision, not any revision
A wiki page and a technote are EDITED: every revision is its own artifact under one stable name. Keyed by artifact hash they piled up in search_doc, and Search's group-by then returned whichever revision matched BEST — so a query found text deleted years ago and linked to a page that no longer contains it. On the live fleet "terrified" matched spatialite_gui's wiki home, which has not said it since 2016; libfossil/home had 117 revisions all searchable. One row per page now, holding the current revision, newest wins on date so a clone may deliver revisions in any order. Every revision stays readable in the page's own history, which is where the question 'what did this used to say' belongs. Existing indexes are rebuilt from wiki_edit and technote at open rather than left to the fold: the fold only fires on artifacts that arrive after this code, so deleting the stale rows alone would empty wiki search until somebody happened to reindex. closed check-in: e262fc246c user: dmestas tags: search-current | |
| 21:40 | merge hit-meta into trunk check-in: c4b5ef5d95 user: dmestas tags: trunk | |
| 21:40 |
search: results say what tells them apart
"use after free" on tk returned three rows identical on screen — same repository, same title, same snippet — because the same fix landed on three branches and the row carried nothing that differed. The only way to find out which one you wanted was to open all three. Rows now carry the hash, date, author and branch for a check-in; the status and date for a ticket; the date and author for wiki, forum and technote. A doc or a file is identified by its path and gets nothing, rather than a date nobody asked for. Decorated by primary key, for the page actually shown: the FTS query groups over a match set that can be thousands of rows, so joining there would decorate all of them to render fifty. On the fleet, decorated AFTER the merge trims — every repository is asked for the full limit so the merge can pick freely, and decorating before it meant 25 indexes times 50 rows to render 50. One definition of the line, shared by both search pages. They are separate templates, which is why the first version of this reached only the per-repo one. closed check-in: 5cc9306b59 user: dmestas tags: hit-meta | |
| 21:20 |
Event log: proof the hooks fired
Read straight off the vocabulary stream — no table, no fold, nothing to keep in sync, so it cannot disagree with what the forge published. The window is the stream's window, 24 hours, and the page says so: a log that let a reader assume history would be lying about a live tail. Asks the consumer how much is pending before fetching. Without that a quiet repository waits out the fetch timeout, which would make 'nothing has happened yet' the slowest page on the site — 2s became 9ms. Linked from the webhooks settings, not given a tab: that is where the question gets asked. closed check-in: a5c5fe771e user: dmestas tags: praise-ui | |
| 21:19 | merge tree-search into trunk check-in: cd06e1345b user: dmestas tags: trunk | |
| 21:19 |
search: the tree is searchable — documentation and source
quarry rendered both as first-class surfaces (the docs tab, the file browser) and could find neither. Fossil searches documentation but not source; this does both, from one pass. One tarball, not a read per file. cat per path costs a subprocess and a store open each: 2,199 files on sqlite is ~31s, against 2.2s for one archive — the tarball is the whole tree in one pass, which is the shape of the question. Tip-only. Indexing every revision of every file would dwarf the rest of the index many times over to answer a question nobody asks; what a file used to say is a question its own history answers. ref carries the version, not just the path: a link has to name the check-in the row was indexed at, and resolving a branch name at click time would be a second source of truth about which tree the row describes. Binaries and files over 1MB are skipped and counted — a search that silently covers less than the tree beside it is the failure this change is about. closed check-in: 8df971b482 user: dmestas tags: tree-search | |
| 20:29 | merge doc-reindex into trunk check-in: 1631ff8ecc user: dmestas tags: trunk | |
| 20:29 |
doc index: survive a rebuild, and never wipe on a failed read
Reset drops every derived table, doc rows included, and the doc pass only fired on a tip change or once at boot — so a reindex left documentation silently unsearchable until somebody happened to commit, which on a mirror of a quiet project is never. Observed live: fossil's 173 documents went to zero after a rebuild and stayed there. ReplaceDocs swaps the whole set, so a pass that found documents and could not read them would overwrite a good index with nothing. It now keeps the previous set and says so. A tree with genuinely no documents still writes an empty set, which is true. closed check-in: 24586a4771 user: dmestas tags: doc-reindex | |
| 20:26 |
Praise UI: a button on the repo, a listing to reverse it
Restores the backend the trunk merge dropped, and adds the two surfaces that get pressed. The button sits in the stats strip beside the clone count, drawn only when there is an account behind it. Pressed state rides on aria-pressed as well as colour. /praised is where praise is revisited and withdrawn. It exists apart from /people/{user} because that page lists CONTRIBUTORS derived from the DAG, and a praiser need never have authored anything. check-in: 0cba46d4ed user: dmestas tags: praise-ui | |
| 20:21 | merge doc-search into trunk check-in: 547934f37f user: dmestas tags: trunk | |
| 20:20 |
search: documentation is searchable (fossil's scope "d")
quarry rendered tree documents as a first-class surface — the docs tab, the reading furniture — and could not find them. A reader could open www/quickstart.wiki and get nothing searching for it. Fossil searches c d t w e f; quarry had everything but d. Indexed at the default branch tip only, asynchronously, on the trigger warmReads already uses: a round that moved the tip, plus once at boot. Not through dagnats — dagnats is optional, a forge without one "simply has no CI rather than a broken one", and search must not inherit that. This is local derivation from the store, the same class of work as the fold, which Reindex already routes around the bus on purpose. isDocPath is exported so the indexer and the docs listing share one definition of what a document is; two would drift and the drift would be invisible. Adding the kind exposed how many places the list is written down: the index, two ops validators, the scope chips, hitOf. The validator was missed and refused every doc search before it reached the query. Two tests now hold the four in agreement. closed check-in: 57a2f8e3d2 user: dmestas tags: doc-search | |
| 20:17 | Suite green; merges clean after clone-counts landed. check-in: d4add44f21 user: dmestas tags: trunk | |
| 20:16 | Suite green; merges clean. Proven against a real server: two clones read 2, a pull leaves it at 2. check-in: bc4fbd1c79 user: dmestas tags: trunk | |
| 19:59 | merge search-links into trunk check-in: 11c9c4a43b user: dmestas tags: trunk | |
| 19:59 |
search: forum and technote results had no link at all
They fell through hitOf's switch with no URL, so the title rendered as
— which a browser resolves to the current page. Clicking a
forum result reloaded the search. The index found them, the facet chips
offered them, and the one step that turns a hit into somewhere to go
was missing.
Their refs are not artifact hashes: searchDoc files a forum post under
its thread root and a technote under its event id, which is what a
reader wants to land on.
The test walks index.SearchKinds rather than a hand-written list, so a
kind added to the index without a link here fails instead of shipping a
dead result. | |
| 19:47 |
Settings fits a phone
The capabilities table sized to an <input size=28> it could not shrink, so the whole page was wider than the viewport and every field ran off the right edge. It scrolls in its own box now. Webhooks were a three-column table with an input and three buttons in one cell — the same bug, added by me. Rows stack instead. Help text cut to one line; the noun chips and the field's datalist say the rest. closed check-in: 0a60aac30c user: dmestas tags: settings-mobile | |
| 19:16 | RFC 0005: state what the clone counters count closed check-in: c83148c71d user: dmestas tags: clone-counts | |
| 19:16 |
Count clones and fetches
A clone reads and writes nothing, so it is the one kind of forge activity that leaves no trace in the DAG at all. The sync edge classifies the protocol's cards: clone SEQNO 1 opens a new clone, later rounds do not, so a big store cloned in forty rounds still counts once. Two sinks: the vocabulary event for anything reacting, and a JetStream counter for how many, which converges across nodes instead of losing increments to a read-modify-write. check-in: dc058fa551 user: dmestas tags: clone-counts | |
| 18:08 |
Suite green across 29 packages; merges clean.
Four commits: the fan-out onto the vocabulary, per-subscription event selection, the settings surface, and the RFC correction that preceded them. check-in: 69fc3f06d1 user: dmestas tags: trunk | |
| 17:20 |
Webhook settings on the repository's own admin screen
Subscriptions were CLI-only. The settings page now lists them, takes an endpoint plus its event patterns, pauses, resumes and deletes — gated on 'a' like everything else there. Patterns are checked before saving. The section is gated on the store being attached, not on the list being non-empty, or the first subscription could never be created. closed check-in: 1c5d758256 user: dmestas tags: hooks-vocabulary | |
| 17:16 |
Subscriptions select their events
A webhook took everything or nothing. It now names vocabulary patterns: '*', a bare noun for every action on it, or an exact kind. Checked at the moment it is set, because a webhook that silently delivers nothing reads as a broken forge. check-in: e9547dcc49 user: dmestas tags: hooks-vocabulary | |
| 17:13 |
Webhooks fan out from the vocabulary, not from the ops layer
A ticket closed by push notified nobody, because only ops queued webhooks. One consumer on quarry.evt.> turns every raised event into deliveries, so arrival path stops mattering. Deletes kindOf: the delivery's kind was sliced off an idempotency key. check-in: f2ea5f5c1a user: dmestas tags: hooks-vocabulary | |
| 17:03 | @dmestas I love the strata idea check-in: edcc983217 user: dmestas tags: trunk | |
| 16:57 |
Close the push gap: derived events all come from the fold
The index holds the state an op would have read, so a pushed artifact gets the same event a forge op would have raised. Ticket transitions and landing verdicts compare folded state before and after; forum, wiki and technote need only the artifact or one lookup. Op sites stop raising derived events, so 2.1's split is now enforced rather than documented. check-in: 06942dcc5d user: dmestas tags: trunk | |
| 16:37 |
RFC 0006: strata — stacked landings, rendered as a chain
The forge half of zit#144. A stack is a chain of dependent landings that land bottom-up; quarry renders the chain, refuses accept above the bottom, and flags a segment whose base was rejected. No new index and no new artifact: the edge is already implied by a landing's target, so a stack is a reading of landings the way a landing is a reading of forum posts. closed check-in: 041ca46258 user: dmestas tags: strata-rfc | |
| 16:32 | RFC 0005: record what implementing the derived events taught check-in: 17317e830f user: dmestas tags: trunk | |
| 16:32 |
Raise the derived events from the fold
checkin.created, branch.created and tag.* come off the artifact; branch.moved comes from the computed branch after RecomputeBranches, because only a branch's first check-in carries a sym- tag. Raising is not idempotent while folding is, so the fold asks the index which hashes are new before writing them. A rebuild raises nothing. check-in: a1b816cd96 user: dmestas tags: trunk | |
| 16:27 | Add the new event-plane files the previous commit omitted check-in: f485551646 user: dmestas tags: trunk | |
| 16:27 |
Publish the vocabulary on quarry.evt.<repo>.<noun>.<action>
RFC 0005 picked zit.evt., which the fold consumer already claims with a trailing wildcard — every vocabulary event would have been handed to the artifact parser. Own stream, own root, tests pinning both. check-in: 3bca39d8f5 user: dmestas tags: trunk | |
| 15:57 |
RFC 0005: dagnats already has the triggers; correct the record
I probed HTTP and concluded dagnats had no scheduler. Wrong lens — triggers are NATS and KV native. It has cron, subject, webhook, durable HTTP, worker-contributed kinds and debounce. So quarry builds none of that: it owes well-named subjects, and the vocabulary IS the hierarchy. check-in: 3aef7f3211 user: dmestas tags: trunk | |
| 15:50 |
events: the vocabulary as code, and what the bus really does
RFC 0005 §1 as internal/evt, named at the eight sites that raise events instead of sliced out of a TxnID — so a ticket change that closes one is ticket.closed, not ticket. Measured the window: a gap IS detectable (first_seq - ack_floor) but the next fetch succeeds silently, so every consumer must check. dagnats has no scheduler; quarry owns the clock. check-in: f08add75f5 user: dmestas tags: trunk | |
| 15:36 |
RFC 0005: verified the bus, added schedules and dispatch
The stream is a one-hour window, so events split: DAG-derived ones are re-derivable after a gap, operational ones need OLTP backing. A cron run and an ad-hoc run are not events — three trigger sources, and schedules take fossil's 'a' so a repo admin owns their own. check-in: 60206e85b2 user: dmestas tags: trunk | |
| 13:11 |
RFC 0005: one named event stream, three consumers
Events are noun.action, subscribed by name. Fossil has no such vocabulary — its hooks are artifact-level and its alerts are eight letters — so the delivery semantics come from its warning that a distributed system cannot promise exactly-once. check-in: dc2bb819a3 user: dmestas tags: trunk | |
| 12:28 | merge timing-wall into trunk check-in: 952100a76b user: dmestas tags: trunk | |
| 12:27 |
timing: the footer reports wall time, not a sum of concurrent calls
A diff is two zit subprocesses on purpose, run at once. Adding their durations printed more time than the whole request took — "rendered in 410.1ms (zit 706.8ms×2)", and "79.94s (zit 159.05s×2)" on a cold tcl page. A number larger than the total reads as a broken page rather than as two calls overlapping. Record now takes the start instant instead of a duration, because a duration cannot say whether two calls overlapped, and the union of the spans is what a reader actually waited. closed check-in: 4aaf089616 user: dmestas tags: timing-wall | |
| 10:47 | merge people-admin into trunk check-in: f8af01dec3 user: dmestas tags: trunk | |
| 10:47 |
people: an admin sees which contributors are also accounts
A contributor is a name an author's client wrote on an artifact; an account is a login on this forge. On a mirror the two barely intersect — fossil has 151 contributors and this forge has two accounts, sharing none — so the page says so, and badges the few that are both. Read-only on purpose. A grant control here would offer to give write access to a string, and on a mirror those strings are chosen by whoever can commit upstream. The door goes to settings, where every row is a real account. account.list is forge-wide and site-admin only, so a repo admin gets the door without the badges rather than an error. closed check-in: 8fb0a669aa user: dmestas tags: people-admin | |