@stravica-ai/digital-operator-pa 0.1.21 → 0.1.23
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/pa/io.mjs +18 -0
- package/dist/engineering-pa-cli/src/pa/work.mjs +81 -44
- 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
|
@@ -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
|
+
}
|