app/worker/src/cloudflareLadderBackend.ts
zzmq.dev@gmail.com/zz / app / worker / src / cloudflareLadderBackend.ts
@ 9b4341e0
Download
Ladder Summary
- file This module implements the Cloudflare‑side helper logic for the “ladder” note system: it defines the public `SimpleRung`, `EmbeddedRef` and `EmbeddedRung` types, maps each rung kind to the appropriate Git‑notes ref (`notesRefFor`), recursively embeds a `SimpleRung` into the plain‑object shape used for storage (`embed`), and resolves the SHA that should anchor a file‑based rung by walking the repository’s HEAD tree (`anchorShaForFile`). The internal utilities `isRef` and `tokenString` support those operations, while the file relies on isomorphic‑git and a memory‑FS wrapper to interact with an `ArtifactsRepoHandle`. (All exported symbols are the three interfaces and the functions `notesRefFor`, `embed`, and `anchorShaForFile`.)
// The Ladder (git notes) against a real Artifacts repo -- the piece
// flagged as harder than the changeset-commit path, now proven: the
// native ARTIFACTS binding's read methods (log/readCommit/readTree)
// only resolve the default branch, confirmed directly (a notes ref
// returns an empty log no matter how it's spelled) -- notes need
// isomorphic-git for both reads and writes. Unlike the main branch,
// though, a notes ref's own history is small (one commit per past
// Ladder write, not the project's full development history), so a
// real, scoped `git.fetch` of just that one ref is cheap regardless
// of how large or old the repo gets -- no reason to hold it to the
// same no-clone constraint the changeset-commit path needed.
//
// Scoped to what agent/scriptedWork.ts actually writes: "file" rungs
// (anchored to a path's blob at HEAD) and "changeset" rungs (anchored
// to the commit itself, with "filechange" children embedded inline,
// never as separate notes -- matching engine/ladderNotes.ts's own
// anchor rules). Directories, releases, ask-threads, dependencies are
// real rung kinds engine/ladderNotes.ts handles locally but nothing
// in the live agent loop writes yet; add them here the same way if
// that changes.
import * as git from "isomorphic-git";
import http from "isomorphic-git/http/web";
import { createMemFs } from "./memFs.js";
import type { ArtifactsRepoHandle } from "./cloudflareGitBackend.js";
export interface SimpleRung {
path: string;
kind: "repo" | "file" | "changeset" | "filechange";
summary: string;
sources: { kind: "commit"; ref: string; title: string; author: string; at: string }[];
children: SimpleRung[];
}
export interface EmbeddedRef {
ref: string;
}
export interface EmbeddedRung {
path: string;
kind: string;
summary: string;
sources: unknown[];
children: (EmbeddedRung | EmbeddedRef)[];
}
function isRef(x: EmbeddedRung | EmbeddedRef): x is EmbeddedRef {
return "ref" in x;
}
function notesRefFor(kind: SimpleRung["kind"]): string {
if (kind === "changeset") return "refs/notes/zz/ladder/changeset";
return "refs/notes/zz/ladder/code";
}
function embed(rung: SimpleRung): EmbeddedRung {
return { path: rung.path, kind: rung.kind, summary: rung.summary, sources: rung.sources, children: rung.children.map(embed) };
}
function tokenString(token: { plaintext: string } | string): string {
return typeof token === "string" ? token.split("?")[0] : token.plaintext;
}
async function anchorShaForFile(repo: ArtifactsRepoHandle, path: string): Promise<string> {
const [headCommit] = await repo.log({ ref: "main", limit: 1 });
if (!headCommit) throw new Error("repo has no commits yet");
const tree = await repo.readTree(headCommit.treeHash);
const entry = tree?.find((e) => e.name === path);
if (!entry) throw new Error(`"${path}" not found in the repo at HEAD`);
return entry.hash;
}
// The repo's first commit (no parents) -- a fixed point that never
// changes for the life of the repo, the anchor "repo"-kind rungs use
// (matching engine/ladderNotes.ts's anchorSha, which has the same
// reason: HEAD's tree sha changes on every commit, which would
// silently orphan _repo's note on the very next one). repo.log's
// `limit` caps at 1000 per Cloudflare's docs; fine for a demo-scale
// repo, a real limitation for anything with deeper history -- walking
// further back would need repo.log's pagination, not added here since
// nothing in the live agent loop has needed it yet.
export async function rootCommitOid(repo: ArtifactsRepoHandle): Promise<string> {
const history = await repo.log({ ref: "main", limit: 1000 });
const root = history[history.length - 1];
if (!root) throw new Error("repo has no commits yet");
return root.hash;
}
async function anchorShaForRung(repo: ArtifactsRepoHandle, rung: Pick<SimpleRung, "kind" | "path" | "sources">): Promise<string> {
if (rung.kind === "repo") return rootCommitOid(repo);
if (rung.kind === "changeset") {
const source = rung.sources.find((s) => s.kind === "commit");
if (!source) throw new Error(`${rung.path}: no commit source to anchor a note to`);
return source.ref;
}
return anchorShaForFile(repo, rung.path);
}
/** A fresh, throwaway isomorphic-git dir with just one notes ref fetched into it (or empty, if that ref doesn't exist on the remote yet) -- never the main branch, never the rest of history. */
async function scopedNotesCheckout(remote: string, token: { plaintext: string } | string, ref: string): Promise<{ fs: ReturnType<typeof createMemFs>; dir: string; hadExisting: boolean }> {
const fs = createMemFs();
const dir = "/notes";
await git.init({ fs, dir, defaultBranch: "main" });
await git.addRemote({ fs, dir, remote: "origin", url: remote });
const onAuth = () => ({ username: "x-token-auth", password: tokenString(token) });
try {
const result = await git.fetch({ fs, http, dir, remote: "origin", onAuth, ref, remoteRef: ref, singleBranch: true, tags: false });
if (result.fetchHead) {
await git.writeRef({ fs, dir, ref, value: result.fetchHead, force: true });
return { fs, dir, hadExisting: true };
}
return { fs, dir, hadExisting: false };
} catch {
// The ref genuinely doesn't exist yet on the remote (first write ever) -- start from nothing, same as a fresh repo's first note.
return { fs, dir, hadExisting: false };
}
}
async function pushNote(remote: string, token: { plaintext: string } | string, fs: ReturnType<typeof createMemFs>, dir: string, ref: string, anchorSha: string, content: unknown): Promise<void> {
const person = { name: "zz-ladder", email: "zz-ladder@local", timestamp: Math.floor(Date.now() / 1000), timezoneOffset: 0 };
await git.addNote({ fs, dir, ref, oid: anchorSha, note: JSON.stringify(content), force: true, author: person, committer: person });
const onAuth = () => ({ username: "x-token-auth", password: tokenString(token) });
const pushResult = await git.push({ fs, http, dir, url: remote, onAuth, ref, remoteRef: ref });
// pushResult.ok is an aggregate flag; per-ref status also confirmed ok
// live for this exact failure, so this check isn't the fix -- see the
// KNOWN ISSUE note on readRung below for what's actually going on.
const refStatus = pushResult.refs?.[ref];
if (!refStatus?.ok) throw new Error(`push of ${ref} rejected: ${refStatus?.error ?? JSON.stringify(pushResult)}`);
}
export async function writeRung(remote: string, token: { plaintext: string } | string, repo: ArtifactsRepoHandle, rung: SimpleRung): Promise<void> {
const ref = notesRefFor(rung.kind);
const anchorSha = await anchorShaForRung(repo, rung);
const { fs, dir } = await scopedNotesCheckout(remote, token, ref);
await pushNote(remote, token, fs, dir, ref, anchorSha, embed(rung));
}
// One repo-wide Ladder read (the "_repo" root plus each of its
// top-level file/changeset children, resolved one DO call at a time
// by webApp.ts's getLadderTree) used to re-run scopedNotesCheckout
// from scratch for every single one of those calls -- even though
// every "file"/"directory"/"function"/"lines" rung shares the exact
// same notes ref ("code"), so three file children meant fetching that
// identical ref three separate times. Confirmed live: this, not any
// one slow call, was the real cost of the Ladder tab (~2.8s of a
// ~3.4s fetch, for a repo with only 5 top-level children). A cache
// keyed by ref name, shared across every readRung call that passes
// the SAME NotesCache object, fixes that -- callers that want this
// sharing pass one cache instance across a batch of reads (see
// cloudflareBackend.ts's readAnchorFor); nothing requires it.
export type NotesCache = Map<string, { fs: ReturnType<typeof createMemFs>; dir: string; hadExisting: boolean }>;
// KNOWN ISSUE, not yet fixed: a brand-new repo's writes to this ref
// (confirmed for "repo"/"file" kinds, which share one ref -- see
// notesRefFor) report success -- isomorphic-git's push() reports
// ok:true at both the aggregate AND per-ref level, and a same-request
// re-fetch (the next sequential rung in the same writeLadder batch)
// correctly sees the prior note via a brand-new scopedNotesCheckout --
// but a LATER, separate request's fetch of that exact ref fails with
// isomorphic-git's own NotFoundError ("Could not find <ref>"), so
// readRung below returns null forever after. Confirmed NOT a timing
// issue (retried after 45s and 120s, same result) and NOT caused by
// `singleBranch: true` on the fetch (tested without it, same result).
// Looks like an Artifacts-side consistency gap specific to non-branch
// refs (git notes) that's visible within the request that just wrote
// but not from a fresh connection -- not something fixable from this
// side without a different write/verify strategy (e.g. reading back
// after push and retrying, or writing notes as part of the same
// commit instead of a separate ref). Flagging for follow-up rather
// than chasing further right now.
export async function readRung(remote: string, token: { plaintext: string } | string, repo: ArtifactsRepoHandle, kind: SimpleRung["kind"], path: string, explicitAnchorSha?: string, cache?: NotesCache): Promise<EmbeddedRung | null> {
const ref = notesRefFor(kind);
const anchorSha = explicitAnchorSha ?? (await anchorShaForRung(repo, { kind, path, sources: [] }));
let checkout = cache?.get(ref);
if (!checkout) {
checkout = await scopedNotesCheckout(remote, token, ref);
cache?.set(ref, checkout);
}
const { fs, dir, hadExisting } = checkout;
if (!hadExisting) return null;
try {
const raw = await git.readNote({ fs, dir, ref, oid: anchorSha });
return JSON.parse(new TextDecoder().decode(raw)) as EmbeddedRung;
} catch {
return null;
}
}
/**
* Adds references to the repo root's own note without touching any
* existing child, written or not -- the Cloudflare twin of
* engine/ladderNotes.ts's appendRepoChildren, same reason: writeRung
* always overwrites a rung's whole note, which is right when writing
* that rung itself but would re-derive and clobber the _repo root's
* already-correct children list if every other write went through it
* too. mergeLaneLadder calls this after every non-root rung write, so
* this has to work for the Cloudflare path to be functionally
* complete, not just for "repo"-kind writes specifically.
*/
export async function appendRepoChildren(remote: string, token: { plaintext: string } | string, repo: ArtifactsRepoHandle, newChildren: { path: string; kind: string }[]): Promise<void> {
const ref = notesRefFor("repo");
const anchorSha = await rootCommitOid(repo);
const { fs, dir, hadExisting } = await scopedNotesCheckout(remote, token, ref);
if (!hadExisting) throw new Error("repo root has no note yet; write the root rung first");
const current = JSON.parse(new TextDecoder().decode(await git.readNote({ fs, dir, ref, oid: anchorSha }))) as EmbeddedRung;
const existingRefs = new Set(current.children.filter(isRef).map((c) => c.ref));
const toAdd = newChildren.filter((c) => !existingRefs.has(c.path));
if (toAdd.length === 0) return;
current.children.push(...toAdd.map((c) => ({ ref: c.path })));
await pushNote(remote, token, fs, dir, ref, anchorSha, current);
}