app/worker/src/cloudflareBackend.ts
zzmq.dev@gmail.com/zz / app / worker / src / cloudflareBackend.ts
@ 527d5696
Download
Ladder Summary
- file The file implements **`createCloudflareBackend`**, a factory that builds an object conforming to the engine’s `Backend` interface for a Cloudflare Durable Object. It wires together lane storage (SQLite via `DurableObjectStorage.sql`), Git operations (`cloudflareGitBackend`), ladder‑tree handling (`cloudflareLadderBackend`), and searchable‑rung indexing, while also providing utilities such as `toSimpleRung`, `collectSearchableRungs`, `collectSearchableResolvedRungs`, and a cached root‑commit OID lookup. No other functions or classes are exported.
// The real Backend (engine/backend.ts) for one Durable Object instance
// -- one DO per repo, folding together the three pieces proven
// separately this session: Lane/Ask/Answer on this DO's own SQLite
// storage, git content (reads native, writes via isomorphic-git's
// minimal-object push) and the Ladder (git notes, via a scoped
// isomorphic-git checkout of just the relevant notes ref) against the
// real Artifacts repo. engine/lane.ts/engine/workqueue.ts never see
// any of this directly -- they only ever call the Backend interface,
// scoped into context via engine/backend.ts's withBackend().
import type { Backend } from "../../engine/backend.js";
import type { Lane } from "../../engine/types.js";
import { REPO_RUNG_PATH, type Rung, type RungKind } from "../../engine/ladder.js";
import type { EmbeddedRung as RealEmbeddedRung } from "../../engine/ladderNotes.js";
import { resolveLadderTree, type ResolvedRung } from "../../engine/ladderRender.js";
import type { CommitRow, FileChangeRow } from "../../engine/ingest.js";
import type { ChangesetFile, CommitAuthor } from "../../engine/changeset.js";
import { commitChangesetOnArtifacts, headOid, readFileAtCommit as readFileAtCommitImpl, type ArtifactsNamespace, type ArtifactsRepoHandle } from "./cloudflareGitBackend.js";
import { writeRung, readRung, appendRepoChildren as appendRepoChildrenImpl, rootCommitOid, type SimpleRung, type NotesCache } from "./cloudflareLadderBackend.js";
import { indexLadderRung, searchLadderIndex } from "./accountsDb.js";
interface RepoInfo {
remote: string;
}
async function withRepo<T>(artifacts: ArtifactsNamespace, name: string, fn: (repo: ArtifactsRepoHandle, remote: string) => Promise<T>): Promise<T> {
const repo = await artifacts.get(name);
try {
const info = (await repo.info()) as RepoInfo;
return await fn(repo, info.remote);
} finally {
repo[Symbol.dispose]?.();
}
}
// Rung's real kind union is wider (directory/release/ask/contributor/
// dependencies/...) than what cloudflareLadderBackend.ts implements
// (repo/file/changeset/filechange, matching what agent/scriptedWork.ts
// actually writes) -- this throws a clear, specific error for any
// other kind rather than silently mishandling it.
function toSimpleRung(rung: Rung): SimpleRung {
const kind = rung.kind;
if (kind !== "repo" && kind !== "file" && kind !== "changeset" && kind !== "filechange") {
throw new Error(`CloudflareBackend: rung kind "${kind}" isn't implemented yet (only repo/file/changeset/filechange are -- see cloudflareLadderBackend.ts's header). Add it there the same way if something now writes it.`);
}
return {
path: rung.path,
kind,
summary: rung.summary,
sources: rung.sources.filter((s) => s.kind === "commit") as SimpleRung["sources"],
children: rung.children.map(toSimpleRung),
};
}
/** Every "file"/"repo" kind rung in the tree, in-memory only (a SimpleRung's children are already embedded inline, not a separate fetch per child) -- the set of things worth putting in the cross-repo search index. "changeset"/"filechange" are historical per-commit entries, not current-state descriptions, so they're skipped. */
function collectSearchableRungs(rung: SimpleRung, out: SimpleRung[]): void {
if (rung.kind === "file" || rung.kind === "repo") out.push(rung);
for (const child of rung.children) collectSearchableRungs(child, out);
}
/** Same filter, over a resolveLadderTree()-resolved tree instead of a SimpleRung -- used by reindexLadder, which walks the Ladder as it reads back (refs followed, not embedded inline the way a fresh write's SimpleRung already is). */
function collectSearchableResolvedRungs(rung: ResolvedRung, out: ResolvedRung[]): void {
if (rung.kind === "file" || rung.kind === "repo") out.push(rung);
for (const child of rung.children) collectSearchableResolvedRungs(child, out);
}
export function createCloudflareBackend(storage: DurableObjectStorage, artifacts: ArtifactsNamespace, artifactsName: string, db: D1Database): Backend {
const sql = storage.sql;
sql.exec("CREATE TABLE IF NOT EXISTS lanes (id TEXT PRIMARY KEY, data TEXT NOT NULL)");
function readLaneSync(laneId: string): Lane | null {
const row = sql.exec("SELECT data FROM lanes WHERE id = ?", laneId).toArray()[0] as { data: string } | undefined;
return row ? (JSON.parse(row.data) as Lane) : null;
}
function writeLaneSync(lane: Lane): void {
sql.exec("INSERT OR REPLACE INTO lanes (id, data) VALUES (?, ?)", lane.id, JSON.stringify(lane));
}
// The repo's root (first) commit never changes once a repo has any
// history -- every later commit only extends the chain forward, so
// re-deriving it from scratch on every single "_repo" rung read
// (rootCommitOid's own repo.log(limit:1000) fetch) was pure waste.
// Confirmed live: this one call was the dominant cost of a repo
// page's Ladder-tab fetch, well past what the zz-meta branch fetch
// (webApp.ts's own earlier fix) cost on its own. Cached in this DO's
// own durable storage, not an in-memory closure -- configure()
// rebuilds this whole Backend object on every call, so a closure
// variable would never survive past the request that set it.
async function cachedRootCommitOid(repo: ArtifactsRepoHandle): Promise<string> {
const cached = await storage.get<string>("rootCommitOid");
if (cached) return cached;
const oid = await rootCommitOid(repo);
await storage.put("rootCommitOid", oid);
return oid;
}
// Shared across every readAnchorFor call this Backend object
// handles (i.e. for as long as this DO instance stays configured
// for this one repo -- see MaintainerDO.configure, which only
// rebuilds this whole Backend, and so this cache, on an actual repo
// change). The Ladder tab's getLadderTree (webApp.ts) resolves the
// "_repo" root's several top-level children (one DO call each) in
// sequence without re-configuring in between, and several of those
// children share the exact same underlying notes ref (every
// file/directory/function/lines rung uses the one "code" ref) --
// without this, each of those calls re-ran scopedNotesCheckout's
// real git fetch of that identical ref from scratch. Confirmed live:
// for a repo with 3 files + 2 changesets at the top level, that was
// 5 redundant checkouts instead of 2.
const notesCache: NotesCache = new Map();
// The WHOLE resolved tree, cached durably and kept up to date on
// every write (writeLadder below, via mergeRungsIntoLadderTreeCache)
// -- not time-based, so a repo nobody's touching stays servable from
// cache indefinitely. Durable storage, not an in-memory field, so it
// survives this DO instance being evicted and cold-starting later --
// the in-memory notesCache above only needs to survive one burst of
// calls (it's just dedup within a resolve), but this is the actual
// "don't make a user wait on N real git-notes fetches to see an
// unchanged Ladder tab" fix, so it should outlive the JS instance,
// not just one request. Direct instruction: "cache at every level
// and invalidate cache only on ladder updates so that it can be
// warmed" -- this is that cache, updated (not merely invalidated)
// on every update so it's never left cold right when it matters
// most: a brand-new write's own notes aren't reliably readable back
// from Artifacts for a while after (readRung's doc comment has the
// full story) -- updating the cache directly from the rungs already
// in hand sidesteps that gap instead of racing it.
const LADDER_TREE_CACHE_KEY = "resolvedLadderTree";
function simpleToResolved(rung: SimpleRung): ResolvedRung {
return { path: rung.path, kind: rung.kind, summary: rung.summary, sources: rung.sources, children: rung.children.map(simpleToResolved) };
}
// Builds/updates the cached tree directly from the rungs just
// written -- no re-read from Artifacts at all, so this can't be
// caught by the same-ref read-after-write gap readRung's doc
// comment describes (confirmed live: even re-resolving through the
// SAME repo handle/token the write itself just used still couldn't
// see a brand-new ref moments after creating it). A "_repo" write
// only replaces root's own fields and keeps whatever children the
// cache already had -- a root rung's own `children` is always []
// from the caller (engine/lane.ts's mergeLaneLadder writes _repo's
// real children via the separate appendRepoChildren call), so
// overwriting children here would just erase them. A non-root rung
// is upserted directly as a child of root: that's also exactly
// where appendRepoChildren would otherwise be linking it, and
// mergeLaneLadder always calls writeLadder then appendRepoChildren
// with the very same rungs in the same merge -- doing it here makes
// appendRepoChildren's own cache step unnecessary for that one real
// call pattern, so it's left alone instead of risking an
// Artifacts-read-based refresh clobbering a cache this already got
// right.
async function mergeRungsIntoLadderTreeCache(rungs: SimpleRung[]): Promise<void> {
let tree = (await storage.get<ResolvedRung>(LADDER_TREE_CACHE_KEY)) ?? null;
for (const rung of rungs) {
if (rung.path === REPO_RUNG_PATH) {
tree = tree ? { ...simpleToResolved(rung), children: tree.children } : simpleToResolved(rung);
continue;
}
// No root to attach to yet, and this write isn't the root
// either -- leave the cache alone; a later real read recomputes
// from Artifacts the old way rather than caching a rootless tree.
if (!tree) continue;
const node = simpleToResolved(rung);
const idx = tree.children.findIndex((c) => c.path === rung.path);
tree = idx >= 0 ? { ...tree, children: tree.children.map((c, i) => (i === idx ? node : c)) } : { ...tree, children: [...tree.children, node] };
}
if (tree) await storage.put(LADDER_TREE_CACHE_KEY, tree);
}
// Extracted out of the `readAnchor` method itself so reindexLadder
// (below) can drive the same path-resolution logic through
// resolveLadderTree without going through `this` -- a plain closure,
// not a bound method, so it stays correct regardless of how this
// Backend object gets passed around.
async function readAnchorFor(path: string): Promise<RealEmbeddedRung | null> {
return withRepo(artifacts, artifactsName, async (repo, remote) => {
const token = await repo.createToken("read", 3600);
// "repo" for the root path. A changeset is anchored by commit
// sha, not a tree path -- readRung's `explicitAnchorSha` param
// exists for exactly this, but needs a real sha from somewhere.
// The established path convention (agent/tools.ts's
// VALID_RUNG_EXAMPLE, cli/commands/work.ts's own comment) is
// "changesets/<full commit sha>", so the sha is sitting right
// there in the path itself -- no index file needed, unlike the
// local backend's stateDir-based one (engine/ladderNotes.ts).
// Confirmed live: before this, a changeset rung could be
// written (writeLadder/toSimpleRung handle "changeset" fine)
// but never read back through this path -- readAnchor always
// fell through to "file", which tried to resolve the path as a
// real tree entry and failed ("not found in the repo at HEAD").
if (path === "_repo") {
return (await readRung(remote, token, repo, "repo", path, await cachedRootCommitOid(repo), notesCache)) as RealEmbeddedRung | null;
}
if (path.startsWith("changesets/")) {
const sha = path.slice("changesets/".length);
return (await readRung(remote, token, repo, "changeset", path, sha, notesCache)) as RealEmbeddedRung | null;
}
return (await readRung(remote, token, repo, "file", path, undefined, notesCache)) as RealEmbeddedRung | null;
});
}
return {
name: "cloudflare",
async ensureCloned(): Promise<void> {
// No-op: the Artifacts repo is assumed to already exist by the
// time any DO method runs against it (created once, out of
// band, the same moment a local repo's first `zz work` call
// would `ensureCloned` it) -- there's no "local" here to clone
// into regardless.
},
async listCommits(): Promise<CommitRow[]> {
return withRepo(artifacts, artifactsName, async (repo) => {
const history = await repo.log({ ref: "main", limit: 1000 });
// Native log() returns newest-first; engine/ingest.ts's real
// listCommitsChronological (and everything built on it) expects
// oldest-first, matching `git log --reverse`.
return history
.slice()
.reverse()
.map((c) => ({ sha: c.hash, parent: c.parents[0] ?? null, author: c.author?.name ?? "", at: new Date(c.authoredAt * 1000).toISOString(), message: c.message }));
});
},
async diffCommit(_repo, commit: CommitRow): Promise<FileChangeRow[]> {
// Tree-level diff only (added/modified/deleted) -- no line-level
// hunks, since there's no `git diff` here, only native tree
// reads (readCommit/readTree). Not exercised by the live agent
// loop today (git_diff is a read-only exploration tool); a real
// limitation flagged rather than silently guessed at if that
// changes -- line-level hunks would need either a real
// isomorphic-git diff (a clone) or a hand-rolled line differ,
// neither built here.
return withRepo(artifacts, artifactsName, async (repo) => {
const c = await repo.readCommit(commit.sha);
if (!c) return [];
const tree = (await repo.readTree(c.treeHash)) ?? [];
if (!commit.parent) return tree.map((e) => ({ path: e.name, status: "added" as const, hunks: [] }));
const parentCommit = await repo.readCommit(commit.parent);
const parentTree = parentCommit ? (await repo.readTree(parentCommit.treeHash)) ?? [] : [];
const parentByName = new Map(parentTree.map((e) => [e.name, e]));
const curByName = new Map(tree.map((e) => [e.name, e]));
const rows: FileChangeRow[] = [];
for (const [name, entry] of curByName) {
const before = parentByName.get(name);
if (!before) rows.push({ path: name, status: "added", hunks: [] });
else if (before.hash !== entry.hash) rows.push({ path: name, status: "modified", hunks: [] });
}
for (const name of parentByName.keys()) {
if (!curByName.has(name)) rows.push({ path: name, status: "deleted", hunks: [] });
}
return rows;
});
},
async readFileAtCommit(_repo, sha: string, path: string): Promise<string> {
// "HEAD" is part of the Backend contract, not a real sha --
// engine/localBackend.ts honors it for free by shelling out to
// real git (`git show HEAD:path`); the native Artifacts binding
// only accepts a literal 40-char sha, so resolve it here first
// (confirmed live: agent/tools.ts's checkNoDroppedExports calls
// this with sha="HEAD" on every commit_changeset).
return withRepo(artifacts, artifactsName, async (repo) => readFileAtCommitImpl(repo, sha === "HEAD" ? await headOid(repo) : sha, path));
},
async commitChangeset(_repo, input: { files: ChangesetFile[]; message: string; author: CommitAuthor }): Promise<string> {
return withRepo(artifacts, artifactsName, async (repo, remote) => {
const token = await repo.createToken("write", 3600);
return commitChangesetOnArtifacts(artifactsName, remote, token, repo, input);
});
},
async readAnchor(_repo, path: string): Promise<RealEmbeddedRung | null> {
return readAnchorFor(path);
},
// The one real fix for "the Ladder tab/zz top is slow" -- see
// engine/backend.ts's own doc comment on this method for why a
// Cloudflare-backed repo needs this where LocalBackend doesn't.
// Cache-first: a cache hit is one durable-storage read, no
// Artifacts/git calls at all, regardless of how many rungs the
// real tree has.
async readLadderTree(): Promise<ResolvedRung | null> {
const cached = await storage.get<ResolvedRung>(LADDER_TREE_CACHE_KEY);
if (cached) return cached;
const { tree } = await resolveLadderTree(readAnchorFor);
await storage.put(LADDER_TREE_CACHE_KEY, tree);
return tree;
},
async writeLadder(_repo, rung: Rung | Rung[]): Promise<void> {
const rungs = (Array.isArray(rung) ? rung : [rung]).map(toSimpleRung);
await withRepo(artifacts, artifactsName, async (repo, remote) => {
const token = await repo.createToken("write", 3600);
for (const one of rungs) await writeRung(remote, token, repo, one);
});
// Built directly from the rungs just written -- see
// mergeRungsIntoLadderTreeCache's doc comment for why this
// doesn't re-read from Artifacts to do it.
await mergeRungsIntoLadderTreeCache(rungs);
// Index after the real write succeeds, not before -- a search
// hit should never point at content that didn't actually land.
const searchable: SimpleRung[] = [];
for (const one of rungs) collectSearchableRungs(one, searchable);
for (const one of searchable) {
// The most recent commit source, not the first -- sources
// accumulates chronologically as a file gets touched again, and
// a search result's commit link should point at what most
// recently shaped this summary, not its very first appearance.
const commitSha = one.sources.length ? one.sources[one.sources.length - 1].ref : null;
await indexLadderRung(db, artifactsName, one.path, one.kind, one.summary, commitSha);
}
},
async appendRepoChildren(_repo, children: { path: string; kind: RungKind }[]): Promise<void> {
await withRepo(artifacts, artifactsName, async (repo, remote) => {
const token = await repo.createToken("write", 3600);
await appendRepoChildrenImpl(remote, token, repo, children);
});
// No cache update needed here: the one real caller
// (engine/lane.ts's mergeLaneLadder) always calls writeLadder
// with these exact same rungs immediately before this, and that
// call's own mergeRungsIntoLadderTreeCache already added them
// as children of root -- this call is just linking the same
// refs on the Artifacts side. If that stops being true for some
// future caller, this would need its own merge or an
// invalidateLadderTreeCache() fallback; not needed today.
},
async searchLadder(query, limit) {
const hits = await searchLadderIndex(db, query, limit, null);
return hits.map((h) => ({ repo: h.repo_slug, path: h.path, kind: h.kind as RungKind, commitSha: h.commit_sha, summary: h.summary }));
},
// Backfill: searchLadder only ever sees what's been written SINCE
// the index existed (confirmed live: a repo with real "tie"/"untie"
// content in a file rung written before this feature shipped was
// invisible to search -- only a repo's newest written rung showed
// up). Walks the repo's current tree the same way the read path
// does (resolveLadderTree + this backend's own anchor resolution,
// following every ref) rather than trusting whatever's already
// embedded in one note, so a rung that's only reachable via a ref
// chain still gets (re)indexed correctly.
async reindexLadder(): Promise<number> {
const { tree } = await resolveLadderTree(readAnchorFor);
const searchable: ResolvedRung[] = [];
collectSearchableResolvedRungs(tree, searchable);
for (const one of searchable) {
const commitSources = one.sources.filter((s) => s.kind === "commit");
const commitSha = commitSources.length ? commitSources[commitSources.length - 1].ref : null;
await indexLadderRung(db, artifactsName, one.path, one.kind, one.summary, commitSha);
}
return searchable.length;
},
async readLane(_repo, laneId: string): Promise<Lane | null> {
return readLaneSync(laneId);
},
async writeLane(_repo, lane: Lane): Promise<void> {
writeLaneSync(lane);
},
async listLanes(): Promise<Lane[]> {
return sql
.exec("SELECT data FROM lanes")
.toArray()
.map((r) => JSON.parse((r as { data: string }).data) as Lane);
},
async withLane(_repo, laneId: string, mutate: (lane: Lane) => void): Promise<Lane> {
return storage.transactionSync(() => {
const lane = readLaneSync(laneId);
if (!lane) throw new Error(`no such lane: ${laneId}`);
mutate(lane);
writeLaneSync(lane);
return lane;
});
},
};
}