Skip to content

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. RegisteredProject carries only id, root_path and label (solutions/ontological/desktop/src-tauri/src/schema/registered_project.rs).
  • createProjectRegistry in solutions/ontological/lib/services/dashboard/project-registry.ts reads those records. Its snapshot cache lives for SNAPSHOT_TTL_MS (1000 ms), so a changed root_path is seen within a second. It caches records, not the project’s files.
  • resolveRequestScope in solutions/ontological/lib/services/dashboard/server.ts turns ?project=<id> into registry.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 in lib/model, lib/services/dashboard, lib/services/config or lib/util; the registry’s 1 second snapshot is the only cache and holds records, not files; server.ts accepts an interval but 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-git is the typed git client. It has fetch (verbs.ts), isAncestor, statusPorcelain, currentBranch, gitCommonDir and merge. merge takes noFf and noCommit but has no fast-forward-only option. checkout-root.ts only discovers checkouts. A merge goes through a request descriptor ({ kind: 'merge', ref } & MergeOptions in backend.ts), which sync.ts and async.ts build with v.merge(ref, opts) from verbs.ts.
  • Ops in this repo (defineOp, for example path: ['project', 'doctor']) are registered from lib/services/project/ops/, and lib/_generated/op-manifest.ts is regenerated with solutions/ontological/scripts/gen-op-manifest.ts. The one long-lived precedent is sdlc dashboard start (lib/services/dashboard/dashboard-service.ts, with dashboard-service-main.ts as its --foreground entry), 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.ts and git operations prefer @sksizer/easy-git.
  • The headless task sdf:serve is solutions/ontological/desktop/moon.yml (lines 248-262 on the branch of #2546). It does not refresh anything.

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 HEAD is not an ancestor of origin/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 (statusPorcelain empty). 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.
  1. Add ffOnly?: boolean to MergeOptions in packages/ts/easy-git/verbs.ts (argv --ff-only). backend.ts ({ kind: 'merge'; ref } & MergeOptions), sync.ts and async.ts take the type from there, so check each still passes the option through, and extend testing.ts if its fake backend enumerates merge options. Add a test in packages/ts/easy-git/tests/integration.test.ts.
  2. Add solutions/ontological/lib/services/project/follow-main.ts exporting followOnce(checkout, options). Steps: git.fetch('origin', 'main') (use git.try so an unreachable remote is a result, not a throw), refuse unless detached and clean, refuse unless isAncestor(HEAD, origin/main), then merge('origin/main', { ffOnly: true }). Return a typed outcome: updated, current, or skipped with a reason.
  3. Write the last outcome (time, commit, outcome, reason) to <git common dir>/sdlc-follow-status.json, so it never dirties the working tree.
  4. Register op sdlc project follow <checkout> [--status] in lib/services/project/ops/follow.ts (defineOp, path: ['project', 'follow']). It is one-shot: it calls followOnce, prints the outcome, and exits non-zero when the outcome is skipped or the fetch failed. --status prints the status file instead. Regenerate lib/_generated/op-manifest.ts.
  5. 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.ts exporting runFollowLoop(checkout, intervalSeconds) (default 60 seconds; calls followOnce, logs each non-current outcome to stderr), with a --foreground entry modelled on dashboard-service-main.ts.
  6. Document the command and the safety rules in the sdf-serve README section of the #2546 branch once that merges (see Dependencies).
AreaNote
packages/ts/easy-git/verbs.tsMergeOptions.ffOnly and its argv
packages/ts/easy-git/backend.ts, sync.ts, async.ts, testing.tsOption 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.tsThe one-shot op
solutions/ontological/lib/_generated/op-manifest.tsRegenerated, not hand edited
solutions/ontological/lib/util/command.tsThe command seam the git client already uses
  • AC-1: In a test repo with a remote, a detached clean checkout one commit behind origin/main is at the origin/main commit after followOnce, and the outcome is updated.
  • AC-2: A checkout with an uncommitted change is unchanged after followOnce, and the outcome is skipped with 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 HEAD has diverged from origin/main is unchanged, and the outcome is skipped.
  • 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-only and 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).
  • bun test packages/ts/easy-git/tests
  • bun test solutions/ontological/lib/services/project
  • bun run solutions/ontological/cli/sdlc.ts project follow --help
  • bun run solutions/ontological/scripts/gen-op-manifest.ts leaves no diff after the commit.
  • rg "child_process" solutions/ontological/lib/services/project/
  • 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 than origin.
  • Starting the follower from moon run sdf:serve.
  • Serving a project from a git ref with no checkout.
  • Needs nothing before it starts. The sdf:serve task itself lives on the open pull request #2546; step 6 (the README line) waits for that merge.

← Back to Tasks