@niroai/niro 0.5.0 → 0.7.0
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/CHANGELOG.md +159 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/add-repo.js +40 -13
- package/src/commands/info.js +70 -0
- package/src/commands/init.js +41 -3
- package/src/commands/release.js +16 -0
- package/src/commands/takeover.js +42 -5
- package/src/commands/watch.js +422 -37
- package/src/index.js +43 -15
- package/src/lib/onboardTarget.js +258 -0
- package/src/lib/tempProject.js +37 -0
- package/src/lib/watchState.js +42 -1
package/src/commands/watch.js
CHANGED
|
@@ -19,6 +19,8 @@ const credentials = require("../lib/credentials");
|
|
|
19
19
|
const prompt = require("../lib/prompt");
|
|
20
20
|
const color = require("../lib/color");
|
|
21
21
|
const folderSync = require("../lib/folderSync");
|
|
22
|
+
const git = require("../lib/git");
|
|
23
|
+
const tempProject = require("../lib/tempProject");
|
|
22
24
|
const {
|
|
23
25
|
SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
|
|
24
26
|
IGNORED_DIRS, isConfigName, chunk,
|
|
@@ -31,10 +33,93 @@ const DEBOUNCE_MS = 2_000;
|
|
|
31
33
|
// so a slower run would clobber a faster one's hash updates. A per-folder guard serializes them (a skipped
|
|
32
34
|
// run is safe — the in-progress one re-hashes the whole tree and so already covers the skipped trigger).
|
|
33
35
|
const syncInFlight = new Set();
|
|
36
|
+
// Folders that are NOT the one their temporary project currently reflects. Uploads from these are held
|
|
37
|
+
// back until the slot returns, so two branches cannot blend into one copy. Set by deltaSyncOnResume and
|
|
38
|
+
// honoured by the live file handler, so one check per sync pass covers both paths rather than asking
|
|
39
|
+
// the server on every save.
|
|
40
|
+
const notHolding = new Set();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long the deletes for a folder must stay quiet before any of them is believed.
|
|
44
|
+
*
|
|
45
|
+
* Not a guess at how long a git checkout takes — the timer RESETS on every new delete, so a 90-second
|
|
46
|
+
* rebase holds the queue for 90 seconds and a three-file checkout holds it for five. This only has to
|
|
47
|
+
* outlast the gap between one file vanishing and the next.
|
|
48
|
+
*/
|
|
49
|
+
const DELETE_SETTLE_MS = Number(process.env.NIRO_DELETE_SETTLE_MS) || 5_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deletes waiting to be believed: resolved folder -> { paths:Set, timer }.
|
|
53
|
+
*
|
|
54
|
+
* A file disappearing is not evidence it was deleted. On 2026-07-31 a customer lost 494 files because
|
|
55
|
+
* a `git checkout` removed a tree, this handler saw the gap and reported every file gone, and git then
|
|
56
|
+
* wrote them all back — by which time the server had destroyed its copies. The client's own log shows
|
|
57
|
+
* the burst (249 consecutive deletes) followed immediately by re-uploads of the very same paths.
|
|
58
|
+
*
|
|
59
|
+
* So deletes are queued rather than sent, and ONE timer per folder is reset by every new delete. When
|
|
60
|
+
* the folder finally goes quiet, whatever is still missing is really gone and is sent; whatever came
|
|
61
|
+
* back was never deleted and is dropped.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately NOT volume-based, and deliberately not "detect a git operation":
|
|
64
|
+
* - a threshold misses a small checkout that touches three files, which is just as wrong;
|
|
65
|
+
* - lock-file detection is git-only, and the same damage comes from `npm ci`, `make clean`, a Docker
|
|
66
|
+
* remount, Perforce (no local lock at all) or any other VCS.
|
|
67
|
+
* Waiting for quiet needs to know none of that. It is the same rule the server already applies before
|
|
68
|
+
* it rebuilds a graph.
|
|
69
|
+
*
|
|
70
|
+
* Holding a delete costs nothing: the graph only updates after its own quiet period, so a few seconds
|
|
71
|
+
* is invisible. A wrong delete costs the file AND its durable backup, at the moment the call lands.
|
|
72
|
+
*/
|
|
73
|
+
const pendingDeletes = new Map();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Folders whose root we could not read, so the "cannot read" error is said ONCE rather than every
|
|
77
|
+
* 60 seconds for as long as the folder stays away. A watched folder that is renamed or unmounted is
|
|
78
|
+
* often left that way for days; repeating a red error every minute trains people to ignore the log,
|
|
79
|
+
* which is how the errors that DO matter get missed.
|
|
80
|
+
*/
|
|
81
|
+
const unreadableRoots = new Set();
|
|
34
82
|
// The state file is rewritten several times during a `niro takeover` (per-file hashes), so debounce
|
|
35
83
|
// before reconciling the watch set. Also the interval for the mtime backstop re-scan below.
|
|
36
84
|
const STATE_RELOAD_DEBOUNCE_MS = 1_500;
|
|
37
85
|
const RESCAN_INTERVAL_MS = 60_000;
|
|
86
|
+
/** Ceiling for the backstop's exponential backoff. Long enough to stop hammering a broken server,
|
|
87
|
+
* short enough that a recovered one is picked up within half an hour without anyone intervening. */
|
|
88
|
+
const BACKSTOP_BACKOFF_MAX_MS = 30 * 60_000;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Per-folder retry state for the backstop re-scan: { failures, nextAttemptAt }.
|
|
92
|
+
*
|
|
93
|
+
* The backstop used to fire every 60s regardless of how many times it had just failed, with no
|
|
94
|
+
* backoff, no cap and no notion that anything was wrong. When the server broke on 8-9 August it
|
|
95
|
+
* minted a fresh sync session every single tick — roughly 1,400 failed requests a day, for two days,
|
|
96
|
+
* per watched folder. The bug was ours, but the client turned it into a flood.
|
|
97
|
+
*/
|
|
98
|
+
const backstopBackoff = new Map();
|
|
99
|
+
|
|
100
|
+
/** Doubling delay, capped. failures=1 gives 2 minutes; the cap is reached after about six. */
|
|
101
|
+
function nextBackoffMs(failures) {
|
|
102
|
+
return Math.min(RESCAN_INTERVAL_MS * Math.pow(2, failures), BACKSTOP_BACKOFF_MAX_MS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** False while a folder is still serving a backoff from its last failure. */
|
|
106
|
+
function shouldAttemptBackstop(key, now = Date.now()) {
|
|
107
|
+
const state = backstopBackoff.get(key);
|
|
108
|
+
return !state || now >= state.nextAttemptAt;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @return the delay applied, so the caller can say how long it is waiting. */
|
|
112
|
+
function recordBackstopFailure(key, now = Date.now()) {
|
|
113
|
+
const failures = (backstopBackoff.get(key)?.failures || 0) + 1;
|
|
114
|
+
const delay = nextBackoffMs(failures);
|
|
115
|
+
backstopBackoff.set(key, { failures, nextAttemptAt: now + delay });
|
|
116
|
+
return delay;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @return true if this success ended a run of failures (so recovery is worth announcing). */
|
|
120
|
+
function recordBackstopSuccess(key) {
|
|
121
|
+
return backstopBackoff.delete(key);
|
|
122
|
+
}
|
|
38
123
|
|
|
39
124
|
async function watch(pathArg, opts = {}) {
|
|
40
125
|
const creds = credentials.requireCreds();
|
|
@@ -189,12 +274,7 @@ async function startWatch(targetDir, opts = {}) {
|
|
|
189
274
|
// that drifted — the reliable, content-checked form of "also pick up changes we didn't get a live
|
|
190
275
|
// event for". Re-reads fresh state, so a folder re-pointed by `niro takeover` or removed by `niro
|
|
191
276
|
// release` mid-session is handled (or skipped) rather than synced to a stale gitId.
|
|
192
|
-
const rescanTimer = setInterval(() => {
|
|
193
|
-
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
194
|
-
if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
|
|
195
|
-
deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true }).catch(err =>
|
|
196
|
-
console.error(color.red(` Backstop re-scan failed for ${targetDir}: ${err.message}`)));
|
|
197
|
-
}, RESCAN_INTERVAL_MS);
|
|
277
|
+
const rescanTimer = setInterval(() => { backstopTick(targetDir); }, RESCAN_INTERVAL_MS);
|
|
198
278
|
|
|
199
279
|
// Start file watcher
|
|
200
280
|
const debounceTimers = new Map();
|
|
@@ -244,40 +324,140 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
244
324
|
if (syncInFlight.has(inFlightKey)) {
|
|
245
325
|
// A sync (startup, live event, or backstop) is already running for this folder — skip to avoid the
|
|
246
326
|
// whole-map fileHashes clobber race; the in-progress run re-hashes the whole tree and covers this trigger.
|
|
247
|
-
|
|
327
|
+
// Reported so the caller can tell "nothing to do" from "the server answered": this run never
|
|
328
|
+
// contacted it, so it is not evidence that anything is working.
|
|
329
|
+
return { skipped: true };
|
|
248
330
|
}
|
|
249
331
|
syncInFlight.add(inFlightKey);
|
|
250
332
|
try {
|
|
251
333
|
const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
|
|
334
|
+
|
|
335
|
+
// Am I still the folder this temporary project reflects? Every folder taken over for one project
|
|
336
|
+
// shares the SAME repo id, so the server cannot tell us apart. If I keep uploading after the slot has
|
|
337
|
+
// moved, my branch blends into the copy that is reflecting someone else, and every answer drawn from
|
|
338
|
+
// it is a mixture — with nothing on screen to say so. Verified live with two worktrees.
|
|
339
|
+
//
|
|
340
|
+
// Nothing is changed or discarded here. The local record is left exactly as it is, so running
|
|
341
|
+
// `niro new-temp-project` here picks straight back up. This only declines to WRITE.
|
|
342
|
+
if (state.takeover && state.takeover.sourceProjectId) {
|
|
343
|
+
const heldBranch = await tempProject.currentBranch(state.projectId, gitId);
|
|
344
|
+
const myBranch = ((git.inspect(targetDir) || {}).branch) || state.branch;
|
|
345
|
+
// A null heldBranch means the server could not be asked. That must NEVER be read as "you lost the
|
|
346
|
+
// slot", or one network hiccup would stop a perfectly healthy folder syncing.
|
|
347
|
+
if (heldBranch && myBranch && heldBranch !== myBranch) {
|
|
348
|
+
if (!notHolding.has(inFlightKey)) {
|
|
349
|
+
notHolding.add(inFlightKey);
|
|
350
|
+
// TWO very different causes, and they need different advice. Another folder bound to the same
|
|
351
|
+
// copy is the stealing case. NO other folder means you simply switched branch in place, and
|
|
352
|
+
// saying "another folder holds it" there sends you hunting for a folder that does not exist —
|
|
353
|
+
// which is exactly what it did on a real repo the first time this ran.
|
|
354
|
+
const otherFolder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
|
|
355
|
+
e.projectId === state.projectId && path.resolve(e.folderPath) !== inFlightKey);
|
|
356
|
+
console.warn(color.yellow(otherFolder
|
|
357
|
+
? ` ${targetDir} is on \`${myBranch}\`, but this project's temporary project is on ` +
|
|
358
|
+
`\`${heldBranch}\`, held by ${otherFolder.folderPath}. Pausing uploads from here so the ` +
|
|
359
|
+
`two branches do not mix. Run \`niro new-temp-project\` here to move it, or ` +
|
|
360
|
+
`\`niro discard-temp-project\` in that folder.`
|
|
361
|
+
: ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still on ` +
|
|
362
|
+
`\`${heldBranch}\` — you switched branch in this folder. Pausing uploads so the two ` +
|
|
363
|
+
`branches do not mix. Run \`niro new-temp-project\` here to move the temporary project ` +
|
|
364
|
+
`onto \`${myBranch}\`.`));
|
|
365
|
+
}
|
|
366
|
+
return { notHolder: true };
|
|
367
|
+
}
|
|
368
|
+
if (notHolding.delete(inFlightKey)) {
|
|
369
|
+
console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
252
372
|
const storedHashes = state.fileHashes || {};
|
|
253
373
|
const currentHashes = {};
|
|
254
374
|
const changed = [];
|
|
255
375
|
const deleted = [];
|
|
256
376
|
|
|
257
377
|
// Walk all currently included files
|
|
258
|
-
const manifest = buildMinimalManifest(targetDir, state.includePatterns,
|
|
378
|
+
const { files: manifest, failedDirs } = buildMinimalManifest(targetDir, state.includePatterns,
|
|
379
|
+
state.excludePatterns);
|
|
380
|
+
|
|
381
|
+
// The repo root itself could not be read — the folder was moved, unmounted, or its permissions
|
|
382
|
+
// changed. EVERY file looks missing, so a delete pass here would wipe the whole project. Abort the
|
|
383
|
+
// pass entirely; a later one recovers by itself once the folder is back.
|
|
384
|
+
if (failedDirs.includes("")) {
|
|
385
|
+
// Said once per folder, then silence until it reads again. Repeating this every 60s for a folder
|
|
386
|
+
// that has simply been moved is noise, and noise is how a real error gets missed.
|
|
387
|
+
if (!unreadableRoots.has(path.resolve(targetDir))) {
|
|
388
|
+
unreadableRoots.add(path.resolve(targetDir));
|
|
389
|
+
console.error(color.red(
|
|
390
|
+
` Cannot read ${targetDir} — skipping this sync entirely rather than reporting every file ` +
|
|
391
|
+
`deleted. Check the folder still exists and is readable. ` +
|
|
392
|
+
`This will not be repeated until it can be read again.`));
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (unreadableRoots.delete(path.resolve(targetDir))) {
|
|
397
|
+
console.log(color.green(` ${targetDir} is readable again — syncing resumed.`));
|
|
398
|
+
}
|
|
259
399
|
|
|
400
|
+
const unreadable = [];
|
|
260
401
|
for (const relativePath of manifest) {
|
|
261
402
|
const absPath = path.join(targetDir, relativePath);
|
|
262
403
|
const hash = watchState.hashFile(absPath);
|
|
263
|
-
if (!hash)
|
|
404
|
+
if (!hash) {
|
|
405
|
+
// The walk that produced this manifest JUST FOUND this file, so it exists. Failing to READ it
|
|
406
|
+
// is not evidence that it was deleted — it is a permission error, a transient lock, a file being
|
|
407
|
+
// rewritten, or simply too many open files. hashFile returns null for every one of those.
|
|
408
|
+
//
|
|
409
|
+
// Skipping the file here used to leave it out of currentHashes, and the loop below reads
|
|
410
|
+
// "absent from currentHashes" as "deleted", so an unreadable file became a delete instruction to
|
|
411
|
+
// the server. Carrying the stored hash forward keeps it in the baseline and out of `deleted`.
|
|
412
|
+
unreadable.push(relativePath);
|
|
413
|
+
if (Object.prototype.hasOwnProperty.call(storedHashes, relativePath)) {
|
|
414
|
+
currentHashes[relativePath] = storedHashes[relativePath];
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
264
418
|
currentHashes[relativePath] = hash;
|
|
265
419
|
if (storedHashes[relativePath] !== hash) {
|
|
266
420
|
changed.push(relativePath);
|
|
267
421
|
}
|
|
268
422
|
}
|
|
269
423
|
|
|
270
|
-
|
|
424
|
+
if (unreadable.length > 0) {
|
|
425
|
+
console.warn(color.yellow(
|
|
426
|
+
` ${unreadable.length} file(s) could not be read this pass — keeping them as-is rather than ` +
|
|
427
|
+
`reporting them deleted. Sample: ${unreadable.slice(0, 5).join(", ")}`));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Files that were in stored hashes but are no longer present in the walk at all.
|
|
431
|
+
//
|
|
432
|
+
// "Not in the walk" only means "deleted" for parts of the tree the walk could actually READ. A
|
|
433
|
+
// directory that failed to list (permissions, a build holding it, too many open files, an NFS or
|
|
434
|
+
// Docker mount blip) yields no entries, which is indistinguishable from one whose files were all
|
|
435
|
+
// removed. Judging those would delete a subtree because we could not look at it.
|
|
436
|
+
const unreadableDirs = [];
|
|
271
437
|
for (const rp of Object.keys(storedHashes)) {
|
|
272
|
-
if (
|
|
273
|
-
|
|
438
|
+
if (Object.prototype.hasOwnProperty.call(currentHashes, rp)) continue;
|
|
439
|
+
if (failedDirs.some(d => d && (rp === d || rp.startsWith(d + "/")))) {
|
|
440
|
+
unreadableDirs.push(rp);
|
|
441
|
+
currentHashes[rp] = storedHashes[rp]; // keep it tracked; we learned nothing about it
|
|
442
|
+
continue;
|
|
274
443
|
}
|
|
444
|
+
deleted.push(rp);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (unreadableDirs.length > 0) {
|
|
448
|
+
console.warn(color.yellow(
|
|
449
|
+
` ${failedDirs.length} directory(ies) could not be read this pass, so ${unreadableDirs.length} ` +
|
|
450
|
+
`file(s) under them are being left alone rather than reported deleted. ` +
|
|
451
|
+
`Sample: ${failedDirs.slice(0, 3).join(", ")}`));
|
|
275
452
|
}
|
|
276
453
|
|
|
277
454
|
if (changed.length === 0 && deleted.length === 0) {
|
|
278
455
|
if (!quiet) console.log(` No changes since last sync.`);
|
|
279
|
-
watchState.
|
|
280
|
-
|
|
456
|
+
watchState.mergeFileHashes(targetDir, { set: currentHashes, expectedGitId: gitId });
|
|
457
|
+
// Nothing to send, so the server was never contacted and this pass is not evidence it is healthy.
|
|
458
|
+
// This is the MAJORITY of backstop ticks: treated as a success it cleared the failure count on the
|
|
459
|
+
// very next tick after one, so the backoff could never grow past its first step.
|
|
460
|
+
return { noContact: true };
|
|
281
461
|
}
|
|
282
462
|
|
|
283
463
|
// A quiet backstop run only speaks up when it actually found something fs.watch missed.
|
|
@@ -287,6 +467,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
287
467
|
// using the same helper as init/add-repo. A gitignored secret edited after
|
|
288
468
|
// onboarding (e.g. a raw .env) must never be re-uploaded here.
|
|
289
469
|
let changedToUpload = changed;
|
|
470
|
+
const gitignoreDropped = [];
|
|
290
471
|
if (changed.length > 0) {
|
|
291
472
|
const drops = folderSync.gitignoreDrops(targetDir, changed);
|
|
292
473
|
if (drops.size > 0) {
|
|
@@ -294,7 +475,7 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
294
475
|
// Don't persist hashes for dropped (gitignored / unverifiable) files — they
|
|
295
476
|
// were never uploaded, so leave them out of state so they're re-evaluated
|
|
296
477
|
// rather than recorded as synced.
|
|
297
|
-
for (const rp of drops) delete currentHashes[rp];
|
|
478
|
+
for (const rp of drops) { delete currentHashes[rp]; gitignoreDropped.push(rp); }
|
|
298
479
|
}
|
|
299
480
|
}
|
|
300
481
|
|
|
@@ -313,21 +494,189 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
313
494
|
await agentApi.completeSync(gitId, sessionId);
|
|
314
495
|
}
|
|
315
496
|
|
|
497
|
+
// Re-check each file immediately before telling the server to remove it. The walk that built this
|
|
498
|
+
// list ran BEFORE the uploads above, which on a large delta take minutes, so by now a file may exist
|
|
499
|
+
// again — or may never have been gone, having merely been unreadable at the wrong moment. The live
|
|
500
|
+
// per-file path already stats before deleting; this one fired blind.
|
|
501
|
+
// Per-file failures are swallowed below so one bad path cannot abort the pass. They still have to be
|
|
502
|
+
// REPORTED, or a pass in which everything failed is indistinguishable from one in which all was well.
|
|
503
|
+
let failures = 0;
|
|
504
|
+
const reappeared = [];
|
|
505
|
+
// Only paths the server actually accepted. Marking a FAILED delete as done drops it from the
|
|
506
|
+
// baseline, and once dropped it can never re-enter `deleted` — so the file stays on the server, stays
|
|
507
|
+
// indexed, and nothing retries it. With the burst guard that is systematic: delete 500 files, the
|
|
508
|
+
// first 100 land, the rest are refused, and all 500 were being forgotten.
|
|
509
|
+
const deletedOk = [];
|
|
316
510
|
for (const rp of deleted) {
|
|
511
|
+
// Existing on disk is NOT on its own a reason to keep it. A file leaves the manifest for two
|
|
512
|
+
// different reasons: it was removed from disk, or it stopped matching the include/exclude
|
|
513
|
+
// patterns. Only the first can "come back". Rescuing on existence alone meant adding an exclude
|
|
514
|
+
// pattern no longer removed the file from Niro — so a path excluded BECAUSE it holds secrets
|
|
515
|
+
// would have gone on being indexed for ever.
|
|
516
|
+
if (fs.existsSync(path.join(targetDir, rp)) && inManifestScope(rp, state)) {
|
|
517
|
+
reappeared.push(rp);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
317
520
|
try {
|
|
318
521
|
await agentApi.deleteFile(gitId, rp);
|
|
522
|
+
deletedOk.push(rp);
|
|
319
523
|
} catch (err) {
|
|
524
|
+
// Swallowed so one bad path cannot abort the pass, but COUNTED: a pass whose deltas are deletes
|
|
525
|
+
// only never throws, so without this the caller saw a clean resolve while every single delete was
|
|
526
|
+
// being refused, cleared the backoff, and retried the whole set 60 seconds later. Forever.
|
|
527
|
+
failures++;
|
|
320
528
|
console.warn(color.yellow(` Warning: failed to delete ${rp}: ${err.message}`));
|
|
321
529
|
}
|
|
322
530
|
}
|
|
323
|
-
|
|
324
|
-
|
|
531
|
+
if (reappeared.length > 0) {
|
|
532
|
+
console.warn(color.yellow(
|
|
533
|
+
` ${reappeared.length} file(s) were back on disk by the time the delete ran — not deleted. ` +
|
|
534
|
+
`Sample: ${reappeared.slice(0, 5).join(", ")}`));
|
|
535
|
+
// Deliberately NOT re-hashed into the baseline. The file may have come back with DIFFERENT bytes
|
|
536
|
+
// (a branch switch or stash pop mid-upload), and it was in `deleted` rather than `changed`, so it
|
|
537
|
+
// was never uploaded — recording the new hash would claim the server holds content it never
|
|
538
|
+
// received. Leaving its OLD entry untouched is what makes the next pass see a hash mismatch and
|
|
539
|
+
// upload it. It is in scope and on disk, so that pass finds it in the manifest.
|
|
540
|
+
}
|
|
541
|
+
const actuallyDeleted = deletedOk;
|
|
542
|
+
|
|
543
|
+
// Merge rather than replace. Removals are explicit: the files we actually deleted, plus any the
|
|
544
|
+
// gitignore policy dropped (those were never uploaded, so they must not stay recorded as synced).
|
|
545
|
+
// expectedGitId: this whole pass computed hashes for `gitId`. If a takeover or discard re-pointed the
|
|
546
|
+
// folder while it ran, these describe a repo it is no longer registered against and must be dropped.
|
|
547
|
+
if (!watchState.mergeFileHashes(targetDir, {
|
|
548
|
+
set: currentHashes,
|
|
549
|
+
remove: [...actuallyDeleted, ...gitignoreDropped],
|
|
550
|
+
patch: { lastSyncedAt: new Date().toISOString() },
|
|
551
|
+
expectedGitId: gitId,
|
|
552
|
+
})) {
|
|
553
|
+
console.log(color.dim(" Folder was re-pointed during this sync — its results were not recorded."));
|
|
554
|
+
}
|
|
325
555
|
console.log(` ${color.green("✓")} Delta sync complete.`);
|
|
556
|
+
return { failures };
|
|
326
557
|
} finally {
|
|
327
558
|
syncInFlight.delete(inFlightKey);
|
|
328
559
|
}
|
|
329
560
|
}
|
|
330
561
|
|
|
562
|
+
/** Queue one vanished path and (re)start this folder's settle timer. */
|
|
563
|
+
function queueDelete(targetDir, relativePath, settleMs = DELETE_SETTLE_MS) {
|
|
564
|
+
const key = path.resolve(targetDir);
|
|
565
|
+
let entry = pendingDeletes.get(key);
|
|
566
|
+
if (!entry) {
|
|
567
|
+
entry = { paths: new Set(), timer: null };
|
|
568
|
+
pendingDeletes.set(key, entry);
|
|
569
|
+
}
|
|
570
|
+
entry.paths.add(relativePath);
|
|
571
|
+
// Reset, never extend-if-absent: while files keep vanishing the operation is still running, and
|
|
572
|
+
// nothing in the queue should be believed until it stops.
|
|
573
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
574
|
+
entry.timer = setTimeout(() => {
|
|
575
|
+
flushPendingDeletes(targetDir).catch(err =>
|
|
576
|
+
console.error(color.red(` Delete flush failed for ${targetDir}: ${err.message}`)));
|
|
577
|
+
}, settleMs);
|
|
578
|
+
if (entry.timer.unref) entry.timer.unref(); // must never hold the daemon open
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The folder has gone quiet. Look again at everything queued: send the deletes that are still real,
|
|
583
|
+
* and forget the ones that came back.
|
|
584
|
+
*
|
|
585
|
+
* A file that returned is left ALONE rather than re-uploaded here. Its baseline entry was never
|
|
586
|
+
* removed (we never sent the delete), so if the bytes changed, git's own write fires a change event
|
|
587
|
+
* that takes the normal upload path, and the 60-second backstop re-scan covers a coalesced event.
|
|
588
|
+
* Duplicating the upload path here would mean duplicating its gitignore and secret checks too.
|
|
589
|
+
*/
|
|
590
|
+
async function flushPendingDeletes(targetDir) {
|
|
591
|
+
const key = path.resolve(targetDir);
|
|
592
|
+
const entry = pendingDeletes.get(key);
|
|
593
|
+
if (!entry) return;
|
|
594
|
+
pendingDeletes.delete(key);
|
|
595
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
596
|
+
|
|
597
|
+
// Re-read state at flush time: a `niro takeover` or `niro release` during the settle window changes
|
|
598
|
+
// (or removes) the gitId these deletes were queued against.
|
|
599
|
+
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
600
|
+
if (!fresh || !fresh.gitId) return;
|
|
601
|
+
if (notHolding.has(key)) return;
|
|
602
|
+
|
|
603
|
+
const stillGone = [];
|
|
604
|
+
const returned = [];
|
|
605
|
+
for (const rp of entry.paths) {
|
|
606
|
+
if (fs.existsSync(path.join(targetDir, rp))) returned.push(rp);
|
|
607
|
+
else stillGone.push(rp);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (returned.length > 0) {
|
|
611
|
+
console.log(color.dim(
|
|
612
|
+
` ${returned.length} file(s) reappeared while settling — not deleted` +
|
|
613
|
+
(returned.length <= 3 ? `: ${returned.join(", ")}` : "")));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (const rp of stillGone) {
|
|
617
|
+
try {
|
|
618
|
+
await agentApi.deleteFile(fresh.gitId, rp);
|
|
619
|
+
watchState.mergeFileHashes(targetDir, {
|
|
620
|
+
remove: [rp],
|
|
621
|
+
patch: { lastSyncedAt: new Date().toISOString() },
|
|
622
|
+
expectedGitId: fresh.gitId,
|
|
623
|
+
});
|
|
624
|
+
console.log(` ${color.yellow("−")} ${rp}`);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
// Keep the baseline entry so a later pass retries. Dropping it here is how a refused delete
|
|
627
|
+
// became permanent (see fb3ff0a).
|
|
628
|
+
console.error(color.red(` Failed to delete ${rp}: ${err.message}`));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* One backstop pass for a folder. Extracted from the interval so the backoff gate at the top can
|
|
636
|
+
* actually be exercised — adversarial review found the gate could be deleted with the whole suite
|
|
637
|
+
* still green, because the only thing that called it was a timer no test drives.
|
|
638
|
+
*/
|
|
639
|
+
function backstopTick(targetDir) {
|
|
640
|
+
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
641
|
+
if (!fresh || !fresh.gitId) return; // folder released — nothing to sync
|
|
642
|
+
// Back off after failures instead of retrying every 60s forever. Retrying at full rate against a
|
|
643
|
+
// server that is failing does not fix anything and buries the real error in its own noise.
|
|
644
|
+
const backoffKey = path.resolve(targetDir);
|
|
645
|
+
if (!shouldAttemptBackstop(backoffKey)) return;
|
|
646
|
+
deltaSyncOnResume(fresh.gitId, targetDir, fresh, { quiet: true })
|
|
647
|
+
.then(result => {
|
|
648
|
+
// A run that never reached the server proves nothing about the server. The in-flight guard
|
|
649
|
+
// returns at once when another sync for this folder is already going, and during an outage a
|
|
650
|
+
// large delta holds that flag for minutes — so a skipped tick was clearing the failure count
|
|
651
|
+
// and printing a green "Sync recovered" in the middle of the incident, while the real run was
|
|
652
|
+
// still heading for its own failure. The count then never grew past one, so the backoff never
|
|
653
|
+
// climbed towards its ceiling and kept hammering every two minutes: both things this backoff
|
|
654
|
+
// was added to stop.
|
|
655
|
+
// Only a pass that actually reached the server and had nothing fail counts as a recovery.
|
|
656
|
+
// A skipped run (another sync in flight) and a no-change run both contact nothing, and a pass
|
|
657
|
+
// whose per-file deletes were all refused resolves cleanly — so all three used to clear the
|
|
658
|
+
// failure count and print a green "Sync recovered" during an outage, which is how the retry rate
|
|
659
|
+
// never decayed and the real error stayed buried.
|
|
660
|
+
if (!result || result.skipped || result.noContact || result.notHolder) return;
|
|
661
|
+
if (result.failures > 0) {
|
|
662
|
+
const delayMs = recordBackstopFailure(backoffKey, Date.now());
|
|
663
|
+
console.error(color.red(
|
|
664
|
+
` ${result.failures} file operation(s) failed for ${targetDir} — retrying in ` +
|
|
665
|
+
`${Math.round(delayMs / 60_000)} min.`));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (recordBackstopSuccess(backoffKey)) {
|
|
669
|
+
console.log(color.green(` Sync recovered for ${targetDir}.`));
|
|
670
|
+
}
|
|
671
|
+
})
|
|
672
|
+
.catch(err => {
|
|
673
|
+
const delayMs = recordBackstopFailure(backoffKey, Date.now());
|
|
674
|
+
console.error(color.red(
|
|
675
|
+
` Backstop re-scan failed for ${targetDir}: ${err.message} — retrying in ` +
|
|
676
|
+
`${Math.round(delayMs / 60_000)} min.`));
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
331
680
|
async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
332
681
|
// Re-read this folder's current state on every event instead of trusting the value
|
|
333
682
|
// captured when the daemon started. A mid-session `niro takeover` re-points the folder
|
|
@@ -335,6 +684,10 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
|
335
684
|
// daemon kept uploading to the OLD gitId — every edit failed with "Repository not found"
|
|
336
685
|
// until the daemon was manually restarted. Reading fresh also picks up include/exclude
|
|
337
686
|
// pattern changes from `niro edit`.
|
|
687
|
+
//
|
|
688
|
+
// Held back by the last sync pass: this folder is not the one its temporary project reflects, so
|
|
689
|
+
// uploading would mix two branches into one copy. Re-checked every pass, so it clears by itself.
|
|
690
|
+
if (notHolding.has(path.resolve(targetDir))) return;
|
|
338
691
|
const fresh = watchState.get(targetDir, api.currentBaseUrl());
|
|
339
692
|
if (!fresh || !fresh.gitId) {
|
|
340
693
|
// Folder was released / is no longer tracked for this backend — stop syncing it.
|
|
@@ -386,25 +739,29 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
|
386
739
|
|
|
387
740
|
const absPath = path.join(targetDir, relativePath);
|
|
388
741
|
|
|
742
|
+
// Symmetry: if we would never UPLOAD this path, we must never ask the server to DELETE it. The
|
|
743
|
+
// accept-set check used to sit below the delete branch, so an editor's scratch file — never sent
|
|
744
|
+
// to us in the first place — still produced a delete call for a path the server had never heard
|
|
745
|
+
// of. That is 21% of all file events in the customer's log (3,226 of 15,223), every one of them a
|
|
746
|
+
// wasted round trip, a wasted journal record, and a count against the server's delete-burst budget
|
|
747
|
+
// that could trip the guard on churn alone.
|
|
748
|
+
//
|
|
749
|
+
// Safe because uploadability is decided by the filename, which does not change for a given path:
|
|
750
|
+
// if it is not uploadable now it was never uploaded. A file the accept set USED to include is
|
|
751
|
+
// still reachable by the backstop re-scan, which works from the stored baseline rather than from
|
|
752
|
+
// live events.
|
|
753
|
+
if (!folderSync.isUploadableName(relativePath)) return;
|
|
754
|
+
|
|
389
755
|
if (!fs.existsSync(absPath)) {
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
await agentApi.deleteFile(gitId, relativePath);
|
|
393
|
-
const hashes = { ...(watchState.get(targetDir) || {}).fileHashes };
|
|
394
|
-
delete hashes[relativePath];
|
|
395
|
-
watchState.update(targetDir, { fileHashes: hashes, lastSyncedAt: new Date().toISOString() });
|
|
396
|
-
console.log(` ${color.yellow("−")} ${relativePath}`);
|
|
397
|
-
} catch (err) {
|
|
398
|
-
console.error(color.red(` Failed to delete ${relativePath}: ${err.message}`));
|
|
399
|
-
}
|
|
756
|
+
// Gone RIGHT NOW is not the same as deleted. Queue it and look again once the folder is quiet.
|
|
757
|
+
queueDelete(targetDir, relativePath);
|
|
400
758
|
return;
|
|
401
759
|
}
|
|
402
760
|
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (!folderSync.isUploadableName(relativePath)) return;
|
|
761
|
+
// It exists, so any queued delete for it was a transient gap (a rewrite, not a removal). Drop it
|
|
762
|
+
// immediately rather than waiting for the settle timer to work it out.
|
|
763
|
+
const pending = pendingDeletes.get(path.resolve(targetDir));
|
|
764
|
+
if (pending) pending.paths.delete(relativePath);
|
|
408
765
|
|
|
409
766
|
// File changed or created
|
|
410
767
|
const newHash = watchState.hashFile(absPath);
|
|
@@ -422,8 +779,11 @@ async function handleFileEvent(gitId, targetDir, relativePath, state) {
|
|
|
422
779
|
|
|
423
780
|
try {
|
|
424
781
|
await agentApi.upsertFile(gitId, relativePath, absPath);
|
|
425
|
-
|
|
426
|
-
|
|
782
|
+
watchState.mergeFileHashes(targetDir, {
|
|
783
|
+
set: { [relativePath]: newHash },
|
|
784
|
+
patch: { lastSyncedAt: new Date().toISOString() },
|
|
785
|
+
expectedGitId: gitId,
|
|
786
|
+
});
|
|
427
787
|
console.log(` ${color.green("+")} ${relativePath}`);
|
|
428
788
|
} catch (err) {
|
|
429
789
|
console.error(color.red(` Failed to sync ${relativePath}: ${err.message}`));
|
|
@@ -435,6 +795,13 @@ function shouldIgnore(relativePath) {
|
|
|
435
795
|
return parts.some(p => IGNORED_DIRS.has(p) || p.startsWith("."));
|
|
436
796
|
}
|
|
437
797
|
|
|
798
|
+
/** Mirrors buildMinimalManifest's membership rule: empty includePatterns means everything is in. */
|
|
799
|
+
function inManifestScope(relativePath, state) {
|
|
800
|
+
const include = state.includePatterns;
|
|
801
|
+
if (!include || include.length === 0) return true;
|
|
802
|
+
return matchesPatterns(relativePath, include, state.excludePatterns);
|
|
803
|
+
}
|
|
804
|
+
|
|
438
805
|
function matchesPatterns(relativePath, includePatterns, excludePatterns) {
|
|
439
806
|
if (!includePatterns || includePatterns.length === 0) return true;
|
|
440
807
|
const matched = includePatterns.some(p => minimatch(relativePath, p));
|
|
@@ -454,12 +821,27 @@ function minimatch(filePath, pattern) {
|
|
|
454
821
|
}
|
|
455
822
|
|
|
456
823
|
/** Walk the directory respecting stored patterns to find all currently included files. */
|
|
824
|
+
/**
|
|
825
|
+
* @returns {{files: string[], failedDirs: string[]}} — `failedDirs` holds the repo-relative directories
|
|
826
|
+
* the walk could NOT read ("" means the repo root itself). A caller must treat those as UNKNOWN, never
|
|
827
|
+
* as empty: a directory that cannot be listed looks exactly like a directory whose files were all
|
|
828
|
+
* deleted, and acting on that difference is how a whole project gets wiped.
|
|
829
|
+
*/
|
|
457
830
|
function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
|
|
458
831
|
const results = [];
|
|
832
|
+
const failedDirs = [];
|
|
459
833
|
|
|
460
834
|
function walk(dir) {
|
|
461
835
|
let entries;
|
|
462
|
-
try {
|
|
836
|
+
try {
|
|
837
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
838
|
+
} catch {
|
|
839
|
+
// Swallowing this was the defect. The walk returned a SHORT list with no signal, the caller read
|
|
840
|
+
// "absent from the walk" as "deleted", and every file under here was reported gone. Renaming a
|
|
841
|
+
// watched folder emptied the whole manifest and deleted the entire project server-side.
|
|
842
|
+
failedDirs.push(path.relative(rootDir, dir).replace(/\\/g, "/"));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
463
845
|
for (const entry of entries) {
|
|
464
846
|
const fullPath = path.join(dir, entry.name);
|
|
465
847
|
const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
@@ -490,9 +872,12 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
|
|
|
490
872
|
}
|
|
491
873
|
}
|
|
492
874
|
walk(rootDir);
|
|
493
|
-
return results;
|
|
875
|
+
return { files: results, failedDirs };
|
|
494
876
|
}
|
|
495
877
|
|
|
496
878
|
// handleFileEvent + deltaSyncOnResume are exported for unit testing the
|
|
497
879
|
// gitignore/fail-closed guards without a live server or fs.watch loop.
|
|
498
|
-
module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure
|
|
880
|
+
module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
|
|
881
|
+
queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
|
|
882
|
+
unreadableRoots,
|
|
883
|
+
nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
|