T-DE6D-served-project-follows-main
State: planning/draft · Impact: medium · Complexity: medium
A project shown in sdf-serve should follow origin/main without anyone pulling by hand. Today the
checkout behind the served project is a detached worktree that goes stale until a person
refreshes it, so the web view quietly shows old planning data. This is product behaviour any
registered project can use, not tooling for this repository.
- A project is a record under
registered_projects/in the vault.RegisteredProjectcarries onlyid,root_pathandlabel(solutions/ontological/desktop/src-tauri/src/schema/registered_project.rs). createProjectRegistryinsolutions/ontological/lib/services/dashboard/project-registry.tsreads those records. Its snapshot cache lives forSNAPSHOT_TTL_MS(1000 ms), so a changedroot_pathis seen within a second. It caches records, not the project’s files.resolveRequestScopeinsolutions/ontological/lib/services/dashboard/server.tsturns?project=<id>intoregistry.pathOf(id), and the dashboard server then reads entities from that directory on disk (readEntitySummaries,scanEntityDir). The reads are plain filesystem reads of the checkout. Checked: there is no module-level cache inlib/model,lib/services/dashboard,lib/services/configorlib/util; the registry’s 1 second snapshot is the only cache and holds records, not files;server.tsaccepts anintervalbut discards it (void interval), so nothing polls or holds state between requests. A checkout that moves forward is seen on the next request.packages/ts/easy-gitis the typed git client. It hasfetch(verbs.ts),isAncestor,statusPorcelain,currentBranch,gitCommonDirandmerge.mergetakesnoFfandnoCommitbut has no fast-forward-only option.checkout-root.tsonly discovers checkouts. A merge goes through a request descriptor ({ kind: 'merge', ref } & MergeOptionsinbackend.ts), whichsync.tsandasync.tsbuild withv.merge(ref, opts)fromverbs.ts.- Ops in this repo (
defineOp, for examplepath: ['project', 'doctor']) are registered fromlib/services/project/ops/, andlib/_generated/op-manifest.tsis regenerated withsolutions/ontological/scripts/gen-op-manifest.ts. The one long-lived precedent issdlc dashboard start(lib/services/dashboard/dashboard-service.ts, withdashboard-service-main.tsas its--foregroundentry), which is a service, not an op. - No existing code both fetches on an interval and reads a ref. The orchestrator watch reads
PR state through
gh api, which is not a git ref. - Shell-outs go through
solutions/ontological/lib/util/command.tsand git operations prefer@sksizer/easy-git. - The headless task
sdf:serveissolutions/ontological/desktop/moon.yml(lines 248-262 on the branch of #2546). It does not refresh anything.
Proposed
Section titled “Proposed”A project can be marked as followed by running a small follower against its checkout. Every
interval the follower fetches origin and fast-forwards the checkout to origin/main, only when
that is safe. The registry and the dashboard server are unchanged: they keep reading the same
directory, which now moves forward by itself.
Decision: managed checkout that fetches on an interval. Why: every reader in the dashboard server
reads files from root_path. Reading from a git ref would mean replacing each of those reads
(entity scan, body read, config load) with git show or ls-tree calls, and would change what
root_path means for the Rust side too. A fetch plus fast-forward touches one place and leaves
all readers alone.
Rejected: serving from a git ref (origin/main read through git show). It needs no fetched
working tree, but it rewrites every filesystem reader and cannot serve files that tools expect on
disk. Revisit only if a project must be served without any checkout.
Safety rules the follower enforces:
- Fast-forward only. If
HEADis not an ancestor oforigin/main, it does nothing and reports. - It acts only on a detached HEAD. A checkout on a branch is user-owned and is never touched.
- It acts only on a clean tree (
statusPorcelainempty). A dirty tree is skipped and reported. - A failed fetch or skipped update leaves the checkout at its last good commit and records the failure.
Approach
Section titled “Approach”- Add
ffOnly?: booleantoMergeOptionsinpackages/ts/easy-git/verbs.ts(argv--ff-only).backend.ts({ kind: 'merge'; ref } & MergeOptions),sync.tsandasync.tstake the type from there, so check each still passes the option through, and extendtesting.tsif its fake backend enumerates merge options. Add a test inpackages/ts/easy-git/tests/integration.test.ts. - Add
solutions/ontological/lib/services/project/follow-main.tsexportingfollowOnce(checkout, options). Steps:git.fetch('origin', 'main')(usegit.tryso an unreachable remote is a result, not a throw), refuse unless detached and clean, refuse unlessisAncestor(HEAD, origin/main), thenmerge('origin/main', { ffOnly: true }). Return a typed outcome:updated,current, orskippedwith a reason. - Write the last outcome (time, commit, outcome, reason) to
<git common dir>/sdlc-follow-status.json, so it never dirties the working tree. - Register op
sdlc project follow <checkout> [--status]inlib/services/project/ops/follow.ts(defineOp,path: ['project', 'follow']). It is one-shot: it callsfollowOnce, prints the outcome, and exits non-zero when the outcome isskippedor the fetch failed.--statusprints the status file instead. Regeneratelib/_generated/op-manifest.ts. - Decision: the interval loop is a service, not an op, because a never-returning op is not an
established shape. Add
lib/services/project/follow-loop.tsexportingrunFollowLoop(checkout, intervalSeconds)(default 60 seconds; callsfollowOnce, logs each non-currentoutcome to stderr), with a--foregroundentry modelled ondashboard-service-main.ts. - Document the command and the safety rules in the sdf-serve README section of the #2546 branch once that merges (see Dependencies).
| Area | Note |
|---|---|
packages/ts/easy-git/verbs.ts | MergeOptions.ffOnly and its argv |
packages/ts/easy-git/backend.ts, sync.ts, async.ts, testing.ts | Option carried through the request, sync and async clients and the fake |
solutions/ontological/lib/services/project/ | New follow-main.ts, follow-loop.ts and tests |
solutions/ontological/lib/services/project/ops/follow.ts | The one-shot op |
solutions/ontological/lib/_generated/op-manifest.ts | Regenerated, not hand edited |
solutions/ontological/lib/util/command.ts | The command seam the git client already uses |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1: In a test repo with a remote, a detached clean checkout one commit behind
origin/mainis at theorigin/maincommit afterfollowOnce, and the outcome isupdated. - AC-2: A checkout with an uncommitted change is unchanged after
followOnce, and the outcome isskippedwith a reason naming the dirty tree. - AC-3: A checkout on a branch (not detached) is unchanged, and the outcome is
skipped. - AC-4: A checkout whose
HEADhas diverged fromorigin/mainis unchanged, and the outcome isskipped. - AC-5: With the remote unreachable, the checkout stays at its previous commit, the status
file records the failure, and
sdlc project follow <checkout>exits non-zero. - AC-6:
merge('x', { ffOnly: true })in easy-git emits--ff-onlyand has a passing test. - AC-7: No file under
solutions/ontological/lib/services/project/spawns git outside@sksizer/easy-git(rg "child_process" solutions/ontological/lib/services/project/returns nothing).
Verification
Section titled “Verification”bun test packages/ts/easy-git/testsbun test solutions/ontological/lib/services/projectbun run solutions/ontological/cli/sdlc.ts project follow --helpbun run solutions/ontological/scripts/gen-op-manifest.tsleaves no diff after the commit.rg "child_process" solutions/ontological/lib/services/project/
Out of scope
Section titled “Out of scope”- Creating or registering the managed checkout, and any registry field that marks a project as followed.
- Following a branch other than
main, or a remote other thanorigin. - Starting the follower from
moon run sdf:serve. - Serving a project from a git ref with no checkout.
Dependencies
Section titled “Dependencies”- Needs nothing before it starts. The
sdf:servetask itself lives on the open pull request #2546; step 6 (the README line) waits for that merge.