@stravica-ai/digital-operator-pa 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engineering-pa-cli/docs/estate-sync.md +90 -9
- package/dist/engineering-pa-cli/src/sync/git.mjs +88 -0
- package/dist/engineering-pa-cli/src/sync/pending-store.mjs +318 -0
- package/dist/engineering-pa-cli/src/sync/run.mjs +272 -5
- package/dist/engineering-pa-cli/src/sync/self-heal.mjs +577 -0
- package/package.json +1 -1
|
@@ -128,11 +128,74 @@ id/tag is estate-side work, not part of this package.
|
|
|
128
128
|
proceeds to push. The audit record adds
|
|
129
129
|
`autoResolved: <N>` + `autoResolvedPaths: [...]` to the `ok` line.
|
|
130
130
|
|
|
131
|
-
If **any** conflicted path is outside the allowlist,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
If **any** conflicted path is outside the allowlist, the tool
|
|
132
|
+
attempts **self-heal on rebase-abort** (R2, w-2026-08-12-004, replaces
|
|
133
|
+
the pre-R2 "abort and wedge" behaviour): estate-sync's own version of
|
|
134
|
+
each conflicting path is queued to
|
|
135
|
+
`<repoRoot>/.cache/estate-sync/pending/<pathSlug>__<firstSeenTs>` with
|
|
136
|
+
a JSON sidecar carrying `originalPath`, `conflictingUpstreamCommitSha`,
|
|
137
|
+
`upstreamBlobShaAtQueue`, `attemptCount`, `firstSeen`, and
|
|
138
|
+
`alertLevel`. The pending content is fsynced BEFORE any git mutation
|
|
139
|
+
runs, so a queue failure never triggers a reset. Then four cascading
|
|
140
|
+
guards fire (HEAD sha equals the sha recorded immediately after
|
|
141
|
+
`git commit`; HEAD^ exists; exactly one commit in `HEAD^..HEAD`;
|
|
142
|
+
exactly one commit ahead of `origin/<branch>`); on any guard failure
|
|
143
|
+
the tool falls through to the classic `fail-rebase` behaviour and
|
|
144
|
+
stays wedged loudly. When all guards pass, `git reset --soft HEAD^`
|
|
145
|
+
drops estate-sync's own commit while leaving BOTH the index and the
|
|
146
|
+
working tree untouched — every non-conflicting path in the dropped
|
|
147
|
+
commit stays staged and its bytes stay on disk for the next tick to
|
|
148
|
+
recommit. Then surgically per conflicting path: newly-added paths get
|
|
149
|
+
`git rm --cached -- <path>` + `fs.unlink` (unstaged and removed from
|
|
150
|
+
disk; their bytes live in pending); modified paths get
|
|
151
|
+
`git checkout HEAD -- <path>` (restored to the parent's version in
|
|
152
|
+
both index and working tree). The audit record uses one of
|
|
153
|
+
`fail-rebase-queued` (alertLevel `warn`, attempts < 5),
|
|
154
|
+
`fail-rebase-stuck` (alertLevel `error`, attempts >= 5), or
|
|
155
|
+
`fail-rebase-needs-human` (divergence detected during drain) with
|
|
156
|
+
exit code **0** — the process exits healthy because the workspace
|
|
157
|
+
itself is fast-forwardable again, while the `fail-*` status routes
|
|
158
|
+
through librarian-alerts (when wired to estate-sync's runs.jsonl).
|
|
159
|
+
|
|
160
|
+
> **Why `--soft` and not `--keep`** (correction landed 2026-08-12 via
|
|
161
|
+
> the PR-120 fresh-context review): `--keep <ref>` updates the
|
|
162
|
+
> working tree to match `<ref>` on every file that differs between
|
|
163
|
+
> HEAD and `<ref>`. In a multi-file estate-sync commit (which is the
|
|
164
|
+
> design — step 6 scoops every in-scope dirty path), that means
|
|
165
|
+
> every non-conflicting path in the dropped commit would be silently
|
|
166
|
+
> destroyed (newly-added files deleted, modifications reverted).
|
|
167
|
+
> `--soft` leaves the index and working tree entirely alone; the
|
|
168
|
+
> surgical scope above touches only the conflicting paths, so
|
|
169
|
+
> non-conflicting content survives across the drop.
|
|
170
|
+
|
|
171
|
+
On every subsequent tick, **DRAIN** runs at the top (inside the PID
|
|
172
|
+
lock, after fetch so `origin/<branch>` refs are fresh), oldest-first.
|
|
173
|
+
For each pending entry the tool compares the sidecar's
|
|
174
|
+
`upstreamBlobShaAtQueue` against origin's blob NOW and picks one of
|
|
175
|
+
three actions:
|
|
176
|
+
- **`already-delivered`** — upstream's current blob bytes exactly
|
|
177
|
+
match the pending content (another writer pushed our version for
|
|
178
|
+
us). Pending removed cleanly, no workspace write.
|
|
179
|
+
- **`staged-for-retry`** — upstream blob is unchanged since queue
|
|
180
|
+
time. Copy the pending bytes back into the workspace at
|
|
181
|
+
`originalPath`; step 6 will stage them and step 7 will rebase
|
|
182
|
+
normally. Pending removed ONLY after commit + push are confirmed
|
|
183
|
+
on HEAD (`finaliseDrainOnPushSuccess`); a crash between drain
|
|
184
|
+
and commit leaves the pending file intact so the next tick
|
|
185
|
+
re-evaluates.
|
|
186
|
+
- **`divergence-held`** — upstream now has a DIFFERENT resolution
|
|
187
|
+
than the sidecar recorded at queue time (a third writer intervened,
|
|
188
|
+
a human reconciled with different bytes, etc.). The sidecar is
|
|
189
|
+
stamped `alertLevel: 'needs-human'` (sticky — never de-escalates
|
|
190
|
+
back to warn or error), and the workspace is NOT touched. This
|
|
191
|
+
blocks the silent-clobber hazard the earlier ratified drain
|
|
192
|
+
sketch had, where re-applying stale bytes would overwrite the
|
|
193
|
+
winner's resolution.
|
|
194
|
+
|
|
195
|
+
Pending queue lives at `.cache/estate-sync/pending/`, gitignored per
|
|
196
|
+
the tooling standard §16. Content-safety invariant: no reset ever
|
|
197
|
+
runs when the queue write fails, and no workspace write ever happens
|
|
198
|
+
when upstream has diverged.
|
|
136
199
|
|
|
137
200
|
### Registry-scoped commit set
|
|
138
201
|
|
|
@@ -229,7 +292,13 @@ One JSON object per line, camelCase keys (§11 of the tooling standard):
|
|
|
229
292
|
| `branch` | string | The current branch name. Present on `ok` and `noop-nothing-to-do`. |
|
|
230
293
|
| `autoResolved` | number | Number of allowlisted regenerated-artifact paths auto-resolved during a rebase conflict. `ok` only, present iff > 0. |
|
|
231
294
|
| `autoResolvedPaths` | string[] | The specific paths that got the `--ours` treatment. `ok` only, paired with `autoResolved`. |
|
|
232
|
-
| `conflictedPaths` | string[] | The disallowed conflicted paths
|
|
295
|
+
| `conflictedPaths` | string[] | The disallowed conflicted paths. Present on `fail-rebase` (self-heal declined, wedged loud), and on any `fail-rebase-queued` / `fail-rebase-stuck` / `fail-rebase-needs-human` (self-heal ran, workspace healthy again). |
|
|
296
|
+
| `selfHealed` | bool | Set to `true` on any self-heal audit record (`fail-rebase-queued`, `-stuck`, or `-needs-human`). |
|
|
297
|
+
| `droppedCommit` | string | 40-hex sha of estate-sync's own commit that self-heal dropped. Present on all self-heal records. |
|
|
298
|
+
| `queued` | object[] | Summary of each queued pending entry (id, originalPath, attemptCount, alertLevel, upstreamBlobShaAtQueue, conflictingUpstreamCommitSha, pendingPath). Present on all self-heal records. |
|
|
299
|
+
| `drained` | object[] | Summary of drain results for this tick (id, originalPath, action, attemptCount, optional alertLevel + upstream blob shas). Present on any tick that saw pending entries at start-of-tick. |
|
|
300
|
+
| `drainFinalised` | string[] | Ids of pending entries removed after commit + push were confirmed on HEAD. Present on `ok` records that carried `drained` entries with action `staged-for-retry`. |
|
|
301
|
+
| `selfHealDeclined` | object | `{ stage, reason }` when self-heal was invoked but refused (queue-write failure, guard failure, reset failure). Paired with `status: fail-rebase` and `exitCode: 2`. |
|
|
233
302
|
|
|
234
303
|
Status vocabulary (closed set):
|
|
235
304
|
|
|
@@ -238,13 +307,25 @@ Status vocabulary (closed set):
|
|
|
238
307
|
| `ok` | 0 | Commit + push (or pull-only) succeeded. |
|
|
239
308
|
| `noop-nothing-to-do` | 0 | Clean tree, no local commits ahead, up-to-date with origin. |
|
|
240
309
|
| `noop-locked` | 0 | Another sync run holds the lock. |
|
|
241
|
-
| `fail-rebase` | 2 | `git pull --rebase` conflicted;
|
|
310
|
+
| `fail-rebase` | 2 | `git pull --rebase` conflicted; self-heal either not attempted (regenerated-artifacts branch already resolved) or declined (guard-failure / queue-write-failure / reset-failure). Workspace is wedged, human reconciliation required. |
|
|
311
|
+
| `fail-rebase-queued` | 0 | Self-heal ran on a non-allowlist conflict, dropped estate-sync's own commit under guard, queued the conflicting content for retry. Workspace is fast-forwardable again; librarian-alerts should surface the queued path (`alertLevel: warn`, `attemptCount < 5`). |
|
|
312
|
+
| `fail-rebase-stuck` | 0 | Same as `fail-rebase-queued` but the pending entry's `attemptCount >= 5` — escalated alert (`alertLevel: error`). |
|
|
313
|
+
| `fail-rebase-needs-human` | 0 | Self-heal ran, drain-time upstream divergence detected on a pending entry — someone else pushed a different resolution while ours was queued. Workspace NOT touched; pending held indefinitely (`alertLevel: needs-human`, sticky). |
|
|
242
314
|
| `fail-push` | 2 | Push rejected even after one rebase-and-retry. Commit is local-only. |
|
|
243
315
|
| `fail-git` | 3 | A `git status`/`add`/`commit` command itself errored (transport or IO). |
|
|
244
316
|
| `fail-cli-bootstrap` | varies | parseArgs / import / startup failure before runSync was reached. |
|
|
245
317
|
|
|
246
|
-
Consumer contract:
|
|
247
|
-
|
|
318
|
+
Consumer contract:
|
|
319
|
+
- **Process health** — check `exitCode`. `0` = the sync process itself is
|
|
320
|
+
healthy (nothing to fix at cron / systemd level). This is the correct
|
|
321
|
+
signal for "is the cron process wedged?"
|
|
322
|
+
- **Alert routing** — check `status.startsWith('fail-')`. Every `fail-*`
|
|
323
|
+
status is a human-visible alert regardless of exitCode, because
|
|
324
|
+
self-heal statuses (`fail-rebase-queued`, `fail-rebase-stuck`,
|
|
325
|
+
`fail-rebase-needs-human`) intentionally exit 0 (workspace is healthy,
|
|
326
|
+
process is healthy) while surfacing that a pending entry needs
|
|
327
|
+
attention. Escalation ladder inside self-heal: `queued` → `stuck` at
|
|
328
|
+
attempt >= 5 → `needs-human` on drain-time divergence (sticky).
|
|
248
329
|
|
|
249
330
|
## Exit codes
|
|
250
331
|
|
|
@@ -198,3 +198,91 @@ export async function rebaseContinue(runner, { cwd }) {
|
|
|
198
198
|
{ cwd, env: { ...process.env, GIT_EDITOR: 'true' } },
|
|
199
199
|
);
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
// Resolve a ref (default HEAD) to its full sha. Returns { code, sha, stderr }.
|
|
203
|
+
// The self-heal path uses this both to record the sha of estate-sync's own
|
|
204
|
+
// commit at commit time AND to verify HEAD is still that sha immediately
|
|
205
|
+
// before dropping it — the guard that stops the reset ever touching a
|
|
206
|
+
// commit we did not just make.
|
|
207
|
+
export async function revParseSha(runner, { cwd, ref = 'HEAD' } = {}) {
|
|
208
|
+
const r = await runner.spawn('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd });
|
|
209
|
+
return { code: r.code, sha: r.stdout.trim(), stderr: r.stderr };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Count commits in a range (e.g. HEAD^..HEAD). Returns { code, count, stderr }.
|
|
213
|
+
// count === 1 is the drop-guard invariant: exactly one commit between HEAD's
|
|
214
|
+
// parent and HEAD (never a merge-commit stack, never zero, never more).
|
|
215
|
+
export async function revListCount(runner, { cwd, range }) {
|
|
216
|
+
const r = await runner.spawn('git', ['rev-list', '--count', range], { cwd });
|
|
217
|
+
const n = Number.parseInt(r.stdout.trim(), 10);
|
|
218
|
+
return { code: r.code, count: Number.isFinite(n) ? n : null, stderr: r.stderr };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// git reset --soft <ref>. Moves HEAD to <ref>, leaves BOTH the index and
|
|
222
|
+
// the working tree untouched. This is the load-bearing primitive for the
|
|
223
|
+
// R2 self-heal path: after dropping our commit, the non-conflicting
|
|
224
|
+
// portion of that commit's diff MUST remain staged in the index so the
|
|
225
|
+
// next tick recommits it. --mixed would unstage (still fine for our
|
|
226
|
+
// purposes but noisier), --hard would obliterate everything, and --keep
|
|
227
|
+
// (which we originally used, corrected 2026-08-12 per the reviewer's
|
|
228
|
+
// blocker on PR #120) actively UPDATES the working tree to match <ref>
|
|
229
|
+
// on every file that differs between HEAD and <ref> — meaning every
|
|
230
|
+
// non-conflicting path in the dropped commit gets silently destroyed.
|
|
231
|
+
// --soft leaves the surgical work to the caller: keep what we want,
|
|
232
|
+
// remove or restore only the conflicting paths we've already queued.
|
|
233
|
+
export async function resetSoft(runner, { cwd, ref }) {
|
|
234
|
+
return runner.spawn('git', ['reset', '--soft', ref], { cwd });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// git rm --cached -- <path>. Unstages a single path — removes it from the
|
|
238
|
+
// index without touching the working tree. Paired with `fs.unlink` in
|
|
239
|
+
// self-heal to surgically un-stage a newly-added conflicting path after
|
|
240
|
+
// the --soft reset (the addition is queued to pending, so we want the
|
|
241
|
+
// path GONE from the index AND from disk without touching any other
|
|
242
|
+
// staged content).
|
|
243
|
+
export async function rmCached(runner, { cwd, paths }) {
|
|
244
|
+
if (paths.length === 0) return { code: 0, stdout: '', stderr: '' };
|
|
245
|
+
return runner.spawn('git', ['rm', '--cached', '--', ...paths], { cwd });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// git hash-object <path>. Computes git's own blob sha for the byte
|
|
249
|
+
// contents of a file, WITHOUT staging or writing anything to the object
|
|
250
|
+
// store. Used by the drain to compare a pending file's bytes against
|
|
251
|
+
// `origin/<branch>:<path>` via matching sha values — binary-safe, no
|
|
252
|
+
// utf8 round-trip, and apples-to-apples (git blob sha both sides).
|
|
253
|
+
export async function hashObject(runner, { cwd, path }) {
|
|
254
|
+
const r = await runner.spawn('git', ['hash-object', '--', path], { cwd });
|
|
255
|
+
return { code: r.code, sha: r.stdout.trim(), stderr: r.stderr };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Does <ref>:<path> exist? `git cat-file -e` exits 0 if yes, non-zero if no.
|
|
259
|
+
// Used by self-heal to decide the working-tree cleanup shape:
|
|
260
|
+
// - path existed at HEAD^ → checkout HEAD^ to restore parent's version
|
|
261
|
+
// - path did NOT exist there → the file was newly added by our commit;
|
|
262
|
+
// remove it from the working tree entirely
|
|
263
|
+
// Getting this branch right is what stops the wedge-loop where our own
|
|
264
|
+
// content, still in the tree after reset, re-conflicts on every tick.
|
|
265
|
+
export async function refPathExists(runner, { cwd, ref, path }) {
|
|
266
|
+
const r = await runner.spawn('git', ['cat-file', '-e', `${ref}:${path}`], { cwd });
|
|
267
|
+
return r.code === 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// git checkout <ref> -- <paths>. Copies the ref's version of each path into
|
|
271
|
+
// BOTH the index and the working tree. Used by self-heal after the
|
|
272
|
+
// reset --keep HEAD^ to overwrite the (now unstaged) conflicting paths with
|
|
273
|
+
// their pre-commit versions, so the next tick's step 6 doesn't re-stage the
|
|
274
|
+
// content we've deliberately queued out of the way.
|
|
275
|
+
export async function checkoutFromRef(runner, { cwd, ref, paths }) {
|
|
276
|
+
if (paths.length === 0) return { code: 0, stdout: '', stderr: '' };
|
|
277
|
+
return runner.spawn('git', ['checkout', ref, '--', ...paths], { cwd });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Does the ref exist locally? Used to check origin/<branch> before we try
|
|
281
|
+
// to inspect it (a fresh clone with no fetches wouldn't have it). We use
|
|
282
|
+
// `git show-ref --verify --quiet` because it's the ref-existence idiom that
|
|
283
|
+
// doesn't fall back to name-rev heuristics and produces no output on either
|
|
284
|
+
// branch.
|
|
285
|
+
export async function refExists(runner, { cwd, ref }) {
|
|
286
|
+
const r = await runner.spawn('git', ['show-ref', '--verify', '--quiet', ref], { cwd });
|
|
287
|
+
return r.code === 0;
|
|
288
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// estate-sync — pending-conflict store.
|
|
2
|
+
//
|
|
3
|
+
// On a rebase conflict that lands OUTSIDE the regenerated-artifacts
|
|
4
|
+
// allowlist, estate-sync now (a) captures our own version of every
|
|
5
|
+
// conflicting path, (b) drops the local commit that made the conflict, and
|
|
6
|
+
// (c) queues the captured content for retry on subsequent ticks. This
|
|
7
|
+
// module owns the on-disk representation of that queue.
|
|
8
|
+
//
|
|
9
|
+
// Layout under `<repoRoot>/.cache/estate-sync/pending/`:
|
|
10
|
+
// <pathSlug>__<firstSeenTsSlug> — raw bytes of estate-sync's version
|
|
11
|
+
// <pathSlug>__<firstSeenTsSlug>.json — sidecar with metadata
|
|
12
|
+
//
|
|
13
|
+
// The pending id is the shared basename; the raw file and the sidecar
|
|
14
|
+
// share it verbatim (sidecar just has `.json` on the end). Path slug is
|
|
15
|
+
// the original path with `/` replaced by `__` and any characters outside
|
|
16
|
+
// [A-Za-z0-9._-] replaced with `-`. First-seen timestamp is stripped of
|
|
17
|
+
// separators (`2026-08-11T16:30:07Z` → `20260811T163007Z`).
|
|
18
|
+
//
|
|
19
|
+
// Two invariants this module enforces:
|
|
20
|
+
// 1. Content-safety on queue — the raw file is opened, written, fsynced,
|
|
21
|
+
// and closed BEFORE the sidecar is written. Sidecar in turn is
|
|
22
|
+
// write-tmp-then-rename (with fsync of the tmp handle and of the
|
|
23
|
+
// containing directory) so a crash never leaves a sidecar pointing at
|
|
24
|
+
// unwritten content.
|
|
25
|
+
// 2. Upsert by originalPath — a second conflict on the SAME workspace
|
|
26
|
+
// path replaces the raw file with our new version, keeps `firstSeen`
|
|
27
|
+
// unchanged (so the file's id stays stable across retries), and
|
|
28
|
+
// bumps `attemptCount`. This is what stops the drain-then-re-conflict
|
|
29
|
+
// loop from flooding the pending dir with parallel entries for one
|
|
30
|
+
// logical conflict.
|
|
31
|
+
//
|
|
32
|
+
// Sidecar fields (see loadAll for canonical field names):
|
|
33
|
+
// id, originalPath, firstSeen, lastAttemptAt, attemptCount,
|
|
34
|
+
// conflictingUpstreamCommitSha, upstreamBlobShaAtQueue (nullable —
|
|
35
|
+
// null when origin had no such path at queue time), ourContentSha,
|
|
36
|
+
// existedInParent (was HEAD^:originalPath present? drives the tree-
|
|
37
|
+
// cleanup branch), alertLevel ('warn' | 'error' | 'needs-human').
|
|
38
|
+
|
|
39
|
+
import { open, mkdir, readFile, readdir, rename, rm, writeFile, stat } from 'node:fs/promises';
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { dirname, join } from 'node:path';
|
|
42
|
+
|
|
43
|
+
// Slug rules: replace path separators with `__`, then any character not in
|
|
44
|
+
// [A-Za-z0-9._-] with `-`, then collapse runs of `-` to one, then trim
|
|
45
|
+
// leading/trailing `.-_`. Bounded to 120 chars (rare-long-path safety on
|
|
46
|
+
// filesystems with 255-byte name limits — the timestamp adds ~16 chars and
|
|
47
|
+
// the `.json` suffix adds 5, leaving headroom).
|
|
48
|
+
export function slugifyPath(p) {
|
|
49
|
+
const out = p
|
|
50
|
+
.replace(/[\/\\]+/g, '__')
|
|
51
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
52
|
+
.replace(/-+/g, '-')
|
|
53
|
+
.replace(/^[.\-_]+|[.\-_]+$/g, '');
|
|
54
|
+
return out.slice(0, 120) || 'path';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Turn an ISO timestamp (`2026-08-11T16:30:07Z`) into a filename-safe
|
|
58
|
+
// compact form (`20260811T163007Z`). Non-matching input is passed through
|
|
59
|
+
// unchanged after the same non-alnum sweep as the path slug.
|
|
60
|
+
export function slugifyTs(ts) {
|
|
61
|
+
return String(ts).replace(/[^A-Za-z0-9]/g, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build the canonical pending id for an (originalPath, firstSeen) pair.
|
|
65
|
+
// The `__` separator between the path slug and the ts slug is deliberate:
|
|
66
|
+
// path slugs never contain `__` (single `_` may appear, `__` is our
|
|
67
|
+
// literal separator token because paths already collapsed `/` to `__` —
|
|
68
|
+
// see slugifyPath which uses `__` for `/` only after the char scrub, so a
|
|
69
|
+
// raw `_` in a filename becomes `_` alone). Adding a further `__` gives
|
|
70
|
+
// an unambiguous split point when reading id fields back out for logs.
|
|
71
|
+
export function pendingId(originalPath, firstSeen) {
|
|
72
|
+
return `${slugifyPath(originalPath)}__${slugifyTs(firstSeen)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function pendingContentPath(pendingDir, id) {
|
|
76
|
+
return join(pendingDir, id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pendingSidecarPath(pendingDir, id) {
|
|
80
|
+
return join(pendingDir, `${id}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sha256Hex(buf) {
|
|
84
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Atomic-ish sidecar write: write to `<name>.tmp`, fsync the tmp handle,
|
|
88
|
+
// rename over `<name>`, then fsync the containing directory so the
|
|
89
|
+
// rename is durable. If the directory fsync throws (unsupported on some
|
|
90
|
+
// filesystems / Windows), swallow that specific failure — the rename
|
|
91
|
+
// itself has already landed on POSIX, and durability was best-effort by
|
|
92
|
+
// contract on non-POSIX. The write + rename ordering is what actually
|
|
93
|
+
// guarantees no half-written sidecars on the read path.
|
|
94
|
+
async function writeJsonAtomic(filePath, obj) {
|
|
95
|
+
const tmp = `${filePath}.tmp`;
|
|
96
|
+
const payload = `${JSON.stringify(obj, null, 2)}\n`;
|
|
97
|
+
const fh = await open(tmp, 'w');
|
|
98
|
+
try {
|
|
99
|
+
await fh.writeFile(payload, 'utf8');
|
|
100
|
+
await fh.sync();
|
|
101
|
+
} finally {
|
|
102
|
+
await fh.close();
|
|
103
|
+
}
|
|
104
|
+
await rename(tmp, filePath);
|
|
105
|
+
try {
|
|
106
|
+
const dh = await open(dirname(filePath), 'r');
|
|
107
|
+
try { await dh.sync(); } finally { await dh.close(); }
|
|
108
|
+
} catch {
|
|
109
|
+
// Directory fsync unsupported on this filesystem — best-effort only.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Content-write with the same durability contract as the sidecar. Content
|
|
114
|
+
// is written as bytes (Buffer or string) — callers pass the raw file
|
|
115
|
+
// content captured from the working tree.
|
|
116
|
+
async function writeBytesAtomic(filePath, buf) {
|
|
117
|
+
const tmp = `${filePath}.tmp`;
|
|
118
|
+
const fh = await open(tmp, 'w');
|
|
119
|
+
try {
|
|
120
|
+
await fh.writeFile(buf);
|
|
121
|
+
await fh.sync();
|
|
122
|
+
} finally {
|
|
123
|
+
await fh.close();
|
|
124
|
+
}
|
|
125
|
+
await rename(tmp, filePath);
|
|
126
|
+
try {
|
|
127
|
+
const dh = await open(dirname(filePath), 'r');
|
|
128
|
+
try { await dh.sync(); } finally { await dh.close(); }
|
|
129
|
+
} catch {
|
|
130
|
+
// See writeJsonAtomic — filesystem may not support directory fsync.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Ensure the pending dir exists. Idempotent; safe to call on every tick.
|
|
135
|
+
export async function ensurePendingDir(pendingDir) {
|
|
136
|
+
await mkdir(pendingDir, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Compose a canonical sidecar object from a queue-time payload. Kept as a
|
|
140
|
+
// pure function so tests can assert the exact shape without file I/O.
|
|
141
|
+
export function makeSidecar({
|
|
142
|
+
id,
|
|
143
|
+
originalPath,
|
|
144
|
+
firstSeen,
|
|
145
|
+
attemptCount = 1,
|
|
146
|
+
conflictingUpstreamCommitSha,
|
|
147
|
+
upstreamBlobShaAtQueue = null,
|
|
148
|
+
ourContentSha,
|
|
149
|
+
existedInParent,
|
|
150
|
+
alertLevel = 'warn',
|
|
151
|
+
}) {
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
originalPath,
|
|
155
|
+
firstSeen,
|
|
156
|
+
lastAttemptAt: firstSeen,
|
|
157
|
+
attemptCount,
|
|
158
|
+
conflictingUpstreamCommitSha: conflictingUpstreamCommitSha ?? null,
|
|
159
|
+
upstreamBlobShaAtQueue,
|
|
160
|
+
ourContentSha,
|
|
161
|
+
existedInParent: Boolean(existedInParent),
|
|
162
|
+
alertLevel,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Look up an existing sidecar for `originalPath`, if any. Used by upsert
|
|
167
|
+
// to preserve `firstSeen` and bump `attemptCount` across a re-queue for
|
|
168
|
+
// the same path.
|
|
169
|
+
export async function findByOriginalPath(pendingDir, originalPath) {
|
|
170
|
+
let entries;
|
|
171
|
+
try {
|
|
172
|
+
entries = await readdir(pendingDir);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err.code === 'ENOENT') return null;
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
for (const name of entries) {
|
|
178
|
+
if (!name.endsWith('.json')) continue;
|
|
179
|
+
let text;
|
|
180
|
+
try {
|
|
181
|
+
text = await readFile(join(pendingDir, name), 'utf8');
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
let obj;
|
|
186
|
+
try { obj = JSON.parse(text); } catch { continue; }
|
|
187
|
+
if (obj && obj.originalPath === originalPath) return obj;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Queue (or re-queue) a conflict. Contract:
|
|
193
|
+
// - Raw content is written and fsynced BEFORE the sidecar is written —
|
|
194
|
+
// if we crash mid-queue, a sidecar-less raw file is orphan (safe:
|
|
195
|
+
// next tick's cleanup can rm it, or leave it as forensic evidence)
|
|
196
|
+
// but there is never a sidecar pointing at unwritten content.
|
|
197
|
+
// - On upsert, `firstSeen` stays anchored to the earliest queue for
|
|
198
|
+
// this path, `attemptCount` is (prevAttempt + 1) if the caller doesn't
|
|
199
|
+
// override, and `alertLevel` follows the escalation ladder (warn →
|
|
200
|
+
// error at attemptCount >= escalateAt; needs-human is caller-set and
|
|
201
|
+
// sticky — divergence detected at drain time never de-escalates).
|
|
202
|
+
// - Returns the fully-populated sidecar object plus its content path.
|
|
203
|
+
export async function queueConflict(pendingDir, {
|
|
204
|
+
originalPath,
|
|
205
|
+
content,
|
|
206
|
+
firstSeen,
|
|
207
|
+
conflictingUpstreamCommitSha,
|
|
208
|
+
upstreamBlobShaAtQueue = null,
|
|
209
|
+
existedInParent,
|
|
210
|
+
escalateAt = 5,
|
|
211
|
+
forceAlertLevel = null,
|
|
212
|
+
}) {
|
|
213
|
+
await ensurePendingDir(pendingDir);
|
|
214
|
+
|
|
215
|
+
const existing = await findByOriginalPath(pendingDir, originalPath);
|
|
216
|
+
const anchoredFirstSeen = existing ? existing.firstSeen : firstSeen;
|
|
217
|
+
const nextAttempt = existing ? (existing.attemptCount || 0) + 1 : 1;
|
|
218
|
+
const priorAlert = existing?.alertLevel || 'warn';
|
|
219
|
+
const escalated = nextAttempt >= escalateAt ? 'error' : 'warn';
|
|
220
|
+
// needs-human is sticky — a caller sets it (divergence detected at drain
|
|
221
|
+
// time) and later requeues never step it back down to warn/error.
|
|
222
|
+
const alertLevel = forceAlertLevel
|
|
223
|
+
|| (priorAlert === 'needs-human' ? 'needs-human' : escalated);
|
|
224
|
+
|
|
225
|
+
const id = pendingId(originalPath, anchoredFirstSeen);
|
|
226
|
+
const contentPath = pendingContentPath(pendingDir, id);
|
|
227
|
+
const sidecarPath = pendingSidecarPath(pendingDir, id);
|
|
228
|
+
|
|
229
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
230
|
+
await writeBytesAtomic(contentPath, buf);
|
|
231
|
+
|
|
232
|
+
const sidecar = makeSidecar({
|
|
233
|
+
id,
|
|
234
|
+
originalPath,
|
|
235
|
+
firstSeen: anchoredFirstSeen,
|
|
236
|
+
attemptCount: nextAttempt,
|
|
237
|
+
conflictingUpstreamCommitSha,
|
|
238
|
+
upstreamBlobShaAtQueue,
|
|
239
|
+
ourContentSha: sha256Hex(buf),
|
|
240
|
+
existedInParent,
|
|
241
|
+
alertLevel,
|
|
242
|
+
});
|
|
243
|
+
sidecar.lastAttemptAt = firstSeen;
|
|
244
|
+
|
|
245
|
+
await writeJsonAtomic(sidecarPath, sidecar);
|
|
246
|
+
|
|
247
|
+
return { sidecar, contentPath, sidecarPath };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Read all pending sidecars, oldest-firstSeen-first. Corrupt sidecars are
|
|
251
|
+
// SKIPPED with a warning attached to the return object — a broken sidecar
|
|
252
|
+
// must never block the drain of well-formed ones.
|
|
253
|
+
export async function loadAll(pendingDir) {
|
|
254
|
+
const out = [];
|
|
255
|
+
const warnings = [];
|
|
256
|
+
let names;
|
|
257
|
+
try {
|
|
258
|
+
names = await readdir(pendingDir);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err.code === 'ENOENT') return { entries: [], warnings };
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
for (const name of names) {
|
|
264
|
+
if (!name.endsWith('.json')) continue;
|
|
265
|
+
const sidecarPath = join(pendingDir, name);
|
|
266
|
+
let text;
|
|
267
|
+
try {
|
|
268
|
+
text = await readFile(sidecarPath, 'utf8');
|
|
269
|
+
} catch (err) {
|
|
270
|
+
warnings.push({ sidecar: name, reason: `read failed: ${err.message}` });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
let obj;
|
|
274
|
+
try { obj = JSON.parse(text); } catch (err) {
|
|
275
|
+
warnings.push({ sidecar: name, reason: `parse failed: ${err.message}` });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!obj || typeof obj !== 'object' || !obj.originalPath || !obj.id) {
|
|
279
|
+
warnings.push({ sidecar: name, reason: 'malformed sidecar (missing id / originalPath)' });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const contentPath = pendingContentPath(pendingDir, obj.id);
|
|
283
|
+
let contentStat;
|
|
284
|
+
try { contentStat = await stat(contentPath); } catch {
|
|
285
|
+
warnings.push({ sidecar: name, reason: 'content file missing — sidecar orphan; skipping' });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
out.push({ sidecar: obj, sidecarPath, contentPath, contentSize: contentStat.size });
|
|
289
|
+
}
|
|
290
|
+
out.sort((a, b) => (a.sidecar.firstSeen < b.sidecar.firstSeen ? -1 : 1));
|
|
291
|
+
return { entries: out, warnings };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Rewrite a sidecar (durably) — used to bump attemptCount / alertLevel
|
|
295
|
+
// without touching the content file. Callers typically compose the next
|
|
296
|
+
// sidecar from the loaded one with `{ ...sidecar, attemptCount: n }`.
|
|
297
|
+
export async function writeSidecar(pendingDir, sidecar) {
|
|
298
|
+
await ensurePendingDir(pendingDir);
|
|
299
|
+
const sidecarPath = pendingSidecarPath(pendingDir, sidecar.id);
|
|
300
|
+
await writeJsonAtomic(sidecarPath, sidecar);
|
|
301
|
+
return { sidecarPath };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Remove a pending entry — both the content file and the sidecar. Contract:
|
|
305
|
+
// removal happens ONLY after the sync has committed AND pushed the
|
|
306
|
+
// pending content upstream. Removal is best-effort per file: either half
|
|
307
|
+
// missing is not an error (idempotent cleanup after a crash mid-remove).
|
|
308
|
+
export async function removePending(pendingDir, id) {
|
|
309
|
+
const contentPath = pendingContentPath(pendingDir, id);
|
|
310
|
+
const sidecarPath = pendingSidecarPath(pendingDir, id);
|
|
311
|
+
await rm(contentPath, { force: true });
|
|
312
|
+
await rm(sidecarPath, { force: true });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Read the raw content bytes for a pending id.
|
|
316
|
+
export async function readContent(pendingDir, id) {
|
|
317
|
+
return readFile(pendingContentPath(pendingDir, id));
|
|
318
|
+
}
|