Skip to content

Commit bb82486

Browse files
Merge pull request #68 from CodeForPhilly/worktree-agent-aacd6f51bc5cf5c00
Port data-repo reconciliation into the API process (closes #66)
2 parents c5f52ba + b2c0977 commit bb82486

7 files changed

Lines changed: 1032 additions & 166 deletions

File tree

apps/api/src/app.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
* 4. trace-id plugin → UUIDv7 traceId on every request
99
* 5. setErrorHandler → single error mapper for all throws
1010
* 6. store plugin → decorates fastify.store from bootStores()
11+
* 6a. reconcile plugin → fetch + ff/rebase/escape-hatch against origin
12+
* (between store and services so in-memory state
13+
* is built from the post-reconciliation tree)
14+
* 6b. push-daemon plugin → starts gitsheets push daemon
15+
* 6c. services plugin → builds in-memory state + FTS
1116
* 7. rate-limit plugin → in-memory counters keyed per-IP + per-account
1217
* 8. idempotency plugin → in-memory map keyed by personId+key
1318
* 9. @fastify/swagger → OpenAPI 3.1 doc generation
@@ -29,6 +34,7 @@ import { envJsonSchema, type Env } from './env.js';
2934
import { mapError } from './lib/errors.js';
3035
import traceIdPlugin from './plugins/trace-id.js';
3136
import storePlugin from './plugins/store.js';
37+
import reconcilePlugin from './plugins/reconcile.js';
3238
import pushDaemonPlugin from './plugins/push-daemon.js';
3339
import servicesPlugin from './plugins/services.js';
3440
import rateLimitPlugin from './plugins/rate-limit.js';
@@ -111,10 +117,16 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
111117
// ----- 6. Store (boots gitsheets + private-store) -----
112118
await fastify.register(storePlugin);
113119

114-
// ----- 6a. Push daemon (pushes public-store commits to CFP_DATA_REMOTE) -----
120+
// ----- 6a. Reconcile (fetch + ff/rebase/escape-hatch against origin) -----
121+
// Runs AFTER store (needs the repo handle) and BEFORE services (so the
122+
// in-memory state is built from the post-reconciliation tree). Skipped
123+
// when CFP_DATA_REMOTE is unset.
124+
await fastify.register(reconcilePlugin);
125+
126+
// ----- 6b. Push daemon (pushes public-store commits to CFP_DATA_REMOTE) -----
115127
await fastify.register(pushDaemonPlugin);
116128

117-
// ----- 6b. Services (loads in-memory state + FTS, boots after store) -----
129+
// ----- 6c. Services (loads in-memory state + FTS, boots after store) -----
118130
await fastify.register(servicesPlugin);
119131

120132
// ----- 7. Rate limiting -----

apps/api/src/lib/data-repo-lock.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Single-slot async lock for serializing data-repo operations that bypass
3+
* `store.transact` — namely, the boot-time reconciliation (this plan) and
4+
* the future hot-reload webhook (#65).
5+
*
6+
* Why not reuse gitsheets' internal `Mutex`? It serializes calls to
7+
* `Repository.transact` but it is per-Repository-instance and isn't exposed
8+
* on the public Repository surface — we'd have to reach through internals.
9+
* A dedicated lock at the Fastify layer is the cleanest place to coordinate
10+
* reconciliation against future webhook-driven transacts.
11+
*
12+
* At boot there's no contention; the lock is uncontended and overhead is
13+
* a microtask. Once #65 lands, the webhook handler acquires this lock
14+
* before fetching + rebuilding the in-memory state, and any concurrent
15+
* write request that calls `store.transact` will wait inside gitsheets'
16+
* internal mutex (which the webhook avoids holding while it does the
17+
* external git fetch). Reconciliation and transacts therefore stay
18+
* mutually exclusive as long as #65's handler acquires this lock for the
19+
* duration of `reconcileDataRepo` AND defers any in-memory rebuild until
20+
* after release.
21+
*/
22+
23+
export type DataRepoLockRelease = () => void;
24+
export type DataRepoLock = () => Promise<DataRepoLockRelease>;
25+
26+
/**
27+
* Create a fresh single-slot lock. Multiple callers calling `acquire()`
28+
* (the returned function) queue FIFO; only one holds the lock at a time.
29+
*
30+
* The returned release function is idempotent — calling it twice releases
31+
* exactly once.
32+
*/
33+
export function createDataRepoLock(): DataRepoLock {
34+
// Tail of the promise chain. Each acquire chains a new pending promise
35+
// onto `tail`; the previous holder's release resolves the prior tail.
36+
let tail: Promise<void> = Promise.resolve();
37+
38+
return async function acquire(): Promise<DataRepoLockRelease> {
39+
let release!: () => void;
40+
const next = new Promise<void>((resolve) => {
41+
release = resolve;
42+
});
43+
const prior = tail;
44+
tail = next;
45+
await prior;
46+
47+
let released = false;
48+
return (): void => {
49+
if (released) return;
50+
released = true;
51+
release();
52+
};
53+
};
54+
}

apps/api/src/plugins/reconcile.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Reconcile plugin.
3+
*
4+
* Replaces the data-repo reconciliation that used to live in
5+
* `deploy/docker/entrypoint.sh`. Registered AFTER `storePlugin` (so the
6+
* repository handle is available) and BEFORE `servicesPlugin` (so the
7+
* in-memory state is built from the post-reconciliation tree).
8+
*
9+
* Behavior:
10+
* - When `CFP_DATA_REMOTE` is unset, reconciliation is skipped entirely
11+
* (typical for local dev against a sibling working tree with no remote).
12+
* - Otherwise: calls `reconcileDataRepo` for the configured branch and
13+
* logs the outcome at the appropriate level:
14+
* - 'conflict-escaped' → ERROR with the `conflictBranch` field, so
15+
* operators see a loud line in production logs.
16+
* - 'fetch-failed' → WARN — non-fatal, the API still boots from
17+
* local state.
18+
* - everything else → INFO.
19+
* - Any other thrown error (corrupt repo, missing branch, etc.) propagates
20+
* and crashes the boot. k8s will restart the pod and the entrypoint will
21+
* re-clone if needed.
22+
*
23+
* Decorates Fastify with:
24+
* - `dataRepoLock` — a single-slot async lock callers use to serialize
25+
* non-`store.transact` git operations (boot reconcile, future webhook).
26+
* - `reconcileDataRepo({ branch })` — a thin wrapper that acquires the
27+
* lock and invokes the state-machine function with the current
28+
* environment. Provided so the future hot-reload webhook (#65) has a
29+
* single call to make.
30+
*/
31+
import type { FastifyInstance } from 'fastify';
32+
import fp from 'fastify-plugin';
33+
34+
import { createDataRepoLock, type DataRepoLock } from '../lib/data-repo-lock.js';
35+
import { reconcileDataRepo, type ReconcileResult } from '../store/reconcile.js';
36+
37+
declare module 'fastify' {
38+
interface FastifyInstance {
39+
/**
40+
* Acquire the data-repo lock. Returns a release function; release is
41+
* idempotent. See `lib/data-repo-lock.ts` for the contract.
42+
*/
43+
dataRepoLock: DataRepoLock;
44+
/**
45+
* Reconcile the local working tree against `CFP_DATA_REMOTE` for the
46+
* given branch under the data-repo lock. Defaults to the configured
47+
* `CFP_DATA_BRANCH`.
48+
*
49+
* Returns the outcome envelope. Throws on unrecoverable filesystem /
50+
* git errors; soft failures (fetch blip, conflict-escape) return a
51+
* non-throwing result.
52+
*/
53+
reconcileDataRepo: (opts?: { branch?: string }) => Promise<ReconcileResult>;
54+
}
55+
}
56+
57+
async function reconcilePlugin(fastify: FastifyInstance): Promise<void> {
58+
const lock = createDataRepoLock();
59+
fastify.decorate('dataRepoLock', lock);
60+
61+
const repoPath = fastify.config.CFP_DATA_REPO_PATH;
62+
const configuredBranch = fastify.config.CFP_DATA_BRANCH;
63+
const remote = fastify.config.CFP_DATA_REMOTE;
64+
65+
// Expose a Fastify-bound wrapper so the future webhook handler (#65) has
66+
// a single call to make. Always under the lock.
67+
fastify.decorate(
68+
'reconcileDataRepo',
69+
async (opts?: { branch?: string }): Promise<ReconcileResult> => {
70+
const branch = opts?.branch ?? configuredBranch;
71+
if (!branch) {
72+
throw new Error(
73+
'reconcileDataRepo: no branch specified and CFP_DATA_BRANCH is unset',
74+
);
75+
}
76+
const release = await lock();
77+
try {
78+
return await reconcileDataRepo({
79+
repoPath,
80+
branch,
81+
logger: fastify.log,
82+
});
83+
} finally {
84+
release();
85+
}
86+
},
87+
);
88+
89+
// Boot-time reconcile: skipped when no remote is configured (dev).
90+
if (!remote) {
91+
fastify.log.info(
92+
'data-repo reconciliation skipped: CFP_DATA_REMOTE unset (dev mode)',
93+
);
94+
return;
95+
}
96+
97+
if (!configuredBranch) {
98+
// Without a branch, we don't know what to reconcile against. Treat as
99+
// a configuration error — entrypoint should set CFP_DATA_BRANCH
100+
// alongside CFP_DATA_REMOTE.
101+
throw new Error(
102+
'data-repo reconciliation: CFP_DATA_REMOTE set but CFP_DATA_BRANCH unset; refusing to guess',
103+
);
104+
}
105+
106+
const release = await lock();
107+
let result: ReconcileResult;
108+
try {
109+
result = await reconcileDataRepo({
110+
repoPath,
111+
branch: configuredBranch,
112+
logger: fastify.log,
113+
});
114+
} finally {
115+
release();
116+
}
117+
118+
// Outcome-specific logging so operators get an at-a-glance line in prod.
119+
switch (result.outcome) {
120+
case 'conflict-escaped':
121+
// LOUD: the operator MUST investigate the named branch.
122+
fastify.log.error(
123+
{
124+
branch: configuredBranch,
125+
conflictBranch: result.conflictBranch,
126+
oldCommit: result.oldCommit,
127+
newCommit: result.newCommit,
128+
ahead: result.ahead,
129+
behind: result.behind,
130+
},
131+
'data-repo reconciliation invoked conflict escape hatch',
132+
);
133+
break;
134+
case 'fetch-failed':
135+
fastify.log.warn(
136+
{ branch: configuredBranch, commit: result.oldCommit },
137+
'data-repo reconciliation: fetch failed; continuing with local state',
138+
);
139+
break;
140+
case 'in-sync':
141+
case 'fast-forwarded':
142+
case 'pushed-ahead':
143+
case 'rebased':
144+
fastify.log.info(
145+
{
146+
branch: configuredBranch,
147+
outcome: result.outcome,
148+
oldCommit: result.oldCommit,
149+
newCommit: result.newCommit,
150+
ahead: result.ahead,
151+
behind: result.behind,
152+
},
153+
'data-repo reconciled',
154+
);
155+
break;
156+
}
157+
}
158+
159+
export default fp(reconcilePlugin, {
160+
name: 'reconcile',
161+
fastify: '5.x',
162+
dependencies: ['store'],
163+
});

0 commit comments

Comments
 (0)