@niroai/niro 0.10.0 → 0.11.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 +117 -0
- package/README.md +185 -249
- package/docs/working-on-branches.md +55 -0
- package/package.json +1 -1
- package/src/commands/cleanup.js +689 -0
- package/src/commands/info.js +51 -2
- package/src/commands/release.js +13 -60
- package/src/commands/takeover.js +52 -2
- package/src/commands/watch.js +93 -25
- package/src/index.js +23 -3
- package/src/lib/folderSync.js +6 -0
- package/src/lib/periodicClaim.js +100 -0
- package/src/lib/printError.js +46 -0
- package/src/lib/takeoverRestore.js +156 -0
- package/src/mcp/setup.js +178 -13
package/src/commands/info.js
CHANGED
|
@@ -17,6 +17,7 @@ const path = require("path");
|
|
|
17
17
|
const api = require("../lib/api");
|
|
18
18
|
const watchState = require("../lib/watchState");
|
|
19
19
|
const tempProject = require("../lib/tempProject");
|
|
20
|
+
const niroProjectFile = require("../lib/niroProjectFile");
|
|
20
21
|
const credentials = require("../lib/credentials");
|
|
21
22
|
const color = require("../lib/color");
|
|
22
23
|
const { resolveContext } = require("../lib/resolveContext");
|
|
@@ -159,14 +160,19 @@ async function info(pathArg, opts = {}) {
|
|
|
159
160
|
const indexed = projects.length > 0;
|
|
160
161
|
const ambiguous = projects.length > 1;
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
const payload = {
|
|
163
164
|
git_root: ctx.gitRoot,
|
|
164
165
|
repo_url: ctx.repoUrl,
|
|
165
166
|
branch: ctx.branch, // null === detached HEAD
|
|
166
167
|
indexed,
|
|
167
168
|
ambiguous,
|
|
168
169
|
projects,
|
|
169
|
-
}
|
|
170
|
+
};
|
|
171
|
+
const emitted = emit(opts, payload);
|
|
172
|
+
// Said last, after the folder's own summary, so it reads as a footnote rather than an error. Never in
|
|
173
|
+
// --json mode: that output has an exact shape and callers parse it.
|
|
174
|
+
if (!opts.json) await warnAboutStalePin(payload);
|
|
175
|
+
return emitted;
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
/** Emit either JSON (exact shape) or a plain-English human summary. */
|
|
@@ -239,4 +245,47 @@ function printHuman(p) {
|
|
|
239
245
|
}
|
|
240
246
|
}
|
|
241
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Tell the developer, HERE, when this folder's `.niro-project` names a project the server does not
|
|
250
|
+
* recognise.
|
|
251
|
+
*
|
|
252
|
+
* The background sweep already finds these, but a line in a daemon log is not a notification — nobody
|
|
253
|
+
* reads those, and the pin is consulted BEFORE the git remote, so a stale one quietly answers every
|
|
254
|
+
* question about the folder. `niro info` is where somebody looks when a folder behaves oddly, so this
|
|
255
|
+
* is where it belongs.
|
|
256
|
+
*
|
|
257
|
+
* Read-only, and silent unless something is actually wrong. A pin that resolves, a pin belonging to a
|
|
258
|
+
* PARENT folder, and a server that cannot be reached all print nothing: this is a hint, and a hint that
|
|
259
|
+
* fires on a bad connection is worse than no hint.
|
|
260
|
+
*/
|
|
261
|
+
async function warnAboutStalePin(p) {
|
|
262
|
+
if (!p || !p.git_root) return;
|
|
263
|
+
let pin;
|
|
264
|
+
try { pin = niroProjectFile.read(p.git_root); } catch { return; }
|
|
265
|
+
if (!pin || !pin.id || !pin.source) return;
|
|
266
|
+
if (path.dirname(path.resolve(pin.source)) !== path.resolve(p.git_root)) return; // a parent's pin
|
|
267
|
+
// The ACCOUNT LIST first, and this is not optional. A pin may hold an ALIAS, and an alias never
|
|
268
|
+
// resolves on the single-project route — asking about a live project by its alias answers
|
|
269
|
+
// "Project not found". Warning on that alone contradicts the sweep, which uses the list and correctly
|
|
270
|
+
// says the pin is fine. This repo's own pin holds an alias, so the first cut of this warning fired on
|
|
271
|
+
// a perfectly healthy folder and pointed at a command that would then report nothing wrong.
|
|
272
|
+
try {
|
|
273
|
+
const listed = await api.get("/api/projects");
|
|
274
|
+
const rows = Array.isArray(listed) ? listed : (listed && listed.projects) || [];
|
|
275
|
+
if (rows.some((r) => r && (r.project_id === pin.id || r.alias === pin.id))) return;
|
|
276
|
+
} catch {
|
|
277
|
+
return; // could not ask: stay quiet
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await api.get(`/api/projects/${encodeURIComponent(pin.id)}`);
|
|
281
|
+
return; // resolves by id — nothing to say
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (!tempProject.isProjectNotFound(err)) return; // could not ask, or not allowed: stay quiet
|
|
284
|
+
}
|
|
285
|
+
console.log("");
|
|
286
|
+
console.log(color.yellow(`This folder's .niro-project names \`${pin.id}\`, which this backend does not recognise.`));
|
|
287
|
+
console.log(color.dim(" It is read before your git remote, so it may be answering for this folder."));
|
|
288
|
+
console.log(color.dim(" The project may simply have been renamed. `niro doctor sync-registry` shows it."));
|
|
289
|
+
}
|
|
290
|
+
|
|
242
291
|
module.exports = { info };
|
package/src/commands/release.js
CHANGED
|
@@ -23,6 +23,7 @@ const niroProjectFile = require("../lib/niroProjectFile");
|
|
|
23
23
|
const prompt = require("../lib/prompt");
|
|
24
24
|
const watchState = require("../lib/watchState");
|
|
25
25
|
const tempProject = require("../lib/tempProject");
|
|
26
|
+
const takeoverRestore = require("../lib/takeoverRestore");
|
|
26
27
|
|
|
27
28
|
const ok = (s) => color.green(s);
|
|
28
29
|
const warn = (s) => color.yellow(s);
|
|
@@ -171,66 +172,11 @@ async function release(pathArg, opts = {}) {
|
|
|
171
172
|
await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
// Restore each folder's local registration.
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const healed = [];
|
|
180
|
-
const failed = [];
|
|
181
|
-
for (const e of folders) {
|
|
182
|
-
try {
|
|
183
|
-
const backup = e.takeover || {};
|
|
184
|
-
const sourceProjectId = backup.sourceProjectId || cloneOf;
|
|
185
|
-
const entryDir = path.resolve(e.folderPath);
|
|
186
|
-
|
|
187
|
-
if (fs.existsSync(entryDir)) {
|
|
188
|
-
const pin = niroProjectFile.read(entryDir);
|
|
189
|
-
const pinIsSwapped = pin && pin.id === tempProjectId
|
|
190
|
-
&& path.dirname(pin.source) === entryDir; // only heal THIS folder's own pin, never a parent's
|
|
191
|
-
if (pinIsSwapped) {
|
|
192
|
-
const recordedNoPin = ("previousPin" in backup) && backup.previousPin === null;
|
|
193
|
-
if (backup.previousPin) {
|
|
194
|
-
niroProjectFile.write(entryDir, backup.previousPin, { overwrite: true });
|
|
195
|
-
healed.push({ folder: e.folderPath, to: backup.previousPin });
|
|
196
|
-
} else if (recordedNoPin) {
|
|
197
|
-
niroProjectFile.remove(entryDir);
|
|
198
|
-
healed.push({ folder: e.folderPath, to: "(no pin — removed)" });
|
|
199
|
-
} else {
|
|
200
|
-
niroProjectFile.write(entryDir, sourceProjectId, { overwrite: true });
|
|
201
|
-
healed.push({ folder: e.folderPath, to: sourceProjectId });
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Watch/daemon registration goes back to the source project regardless of pin style.
|
|
207
|
-
watchState.set(e.folderPath, {
|
|
208
|
-
...e,
|
|
209
|
-
gitId: backup.previousGitId || null,
|
|
210
|
-
projectId: sourceProjectId,
|
|
211
|
-
// The baseline describes what the TEMPORARY project holds, so it MUST NOT survive the move
|
|
212
|
-
// back to the source repo. Carrying it across told the daemon the source already had every
|
|
213
|
-
// file at its sandbox-era fingerprint — which it did not, because those edits only ever went
|
|
214
|
-
// to the temporary copy. The next pass then compared disk against that baseline, found no
|
|
215
|
-
// difference, and uploaded nothing. Every edit made during the temporary project was silently
|
|
216
|
-
// lost to the source project, with no error and nothing to prompt a re-sync.
|
|
217
|
-
//
|
|
218
|
-
// Clearing it makes the next pass treat every file as new and upload it. That is a full
|
|
219
|
-
// re-upload once, which is the cost of being sure: the client cannot know which files the
|
|
220
|
-
// source repo is missing, so the only safe assumption is all of them.
|
|
221
|
-
//
|
|
222
|
-
// The opposite direction already gets this right — takeover.js seeds fileHashes with what it
|
|
223
|
-
// actually uploaded to the new gitId. A baseline belongs to ONE gitId; when that changes, so
|
|
224
|
-
// must it.
|
|
225
|
-
fileHashes: {},
|
|
226
|
-
lastSyncedAt: null,
|
|
227
|
-
takeover: undefined,
|
|
228
|
-
}, e.apiUrl || baseUrl);
|
|
229
|
-
repinned.push(e.folderPath);
|
|
230
|
-
} catch (err) {
|
|
231
|
-
failed.push({ folder: e.folderPath, error: err.message });
|
|
232
|
-
}
|
|
233
|
-
}
|
|
175
|
+
// Restore each folder's local registration. Shared with `niro cleanup-temp-projects`, which reaches
|
|
176
|
+
// the same point by a different route (it finds copies already gone from the server instead of being
|
|
177
|
+
// pointed at one), so the loop itself lives in lib/takeoverRestore.
|
|
178
|
+
const { repinned, healed, failed, skipped } = takeoverRestore.restoreFolders(folders,
|
|
179
|
+
{ tempProjectId, cloneOf, baseUrl });
|
|
234
180
|
|
|
235
181
|
if (alreadyGone) {
|
|
236
182
|
console.log(`\n ${ok("✓")} Temporary project ${color.bold(tempProjectId)} was already gone from the server` +
|
|
@@ -242,6 +188,13 @@ async function release(pathArg, opts = {}) {
|
|
|
242
188
|
} else {
|
|
243
189
|
console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
|
|
244
190
|
}
|
|
191
|
+
if (skipped.length) {
|
|
192
|
+
// Somebody re-pointed the folder while this ran. Leaving it alone is the safe half; saying so is
|
|
193
|
+
// the other half, because a silently skipped folder looks exactly like a repaired one.
|
|
194
|
+
console.log(warn(`\n ⚠ ${skipped.length} folder(s) were re-registered while this ran and were ` +
|
|
195
|
+
"left alone:"));
|
|
196
|
+
for (const s of skipped) console.log(color.dim(` ${s.folder}`));
|
|
197
|
+
}
|
|
245
198
|
if (healed.length) {
|
|
246
199
|
console.log(color.dim(" Healed old-style pins:"));
|
|
247
200
|
for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
|
package/src/commands/takeover.js
CHANGED
|
@@ -147,7 +147,38 @@ async function takeover(pathArg, opts = {}) {
|
|
|
147
147
|
// 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
|
|
148
148
|
// the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
|
|
149
149
|
// cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
|
|
150
|
-
|
|
150
|
+
let outcome;
|
|
151
|
+
try {
|
|
152
|
+
outcome = await requestClone(source.project_id, source.git_id, branch);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// The server refuses a copy when you already hold the limit, and its message is good: it says how
|
|
155
|
+
// many, what the limit is, and which BRANCHES the copies are for. What it cannot say is where those
|
|
156
|
+
// branches are checked out, because that is a fact about this machine — and its own advice is "run
|
|
157
|
+
// `niro discard-temp-project` in the folder for one you have finished with". So the folder is the
|
|
158
|
+
// one thing the developer still has to work out, and it is the one thing this side knows.
|
|
159
|
+
//
|
|
160
|
+
// Matched on the refusal, not attached to every failure. The closing line of this hint tells you to
|
|
161
|
+
// discard a copy, and that is only sound advice when capacity is what stopped you: on a dropped
|
|
162
|
+
// connection it would send somebody deleting a working copy for nothing. The existing one-slot
|
|
163
|
+
// guard tests caught exactly that by failing when the hint arrived on their sentinel error.
|
|
164
|
+
//
|
|
165
|
+
// The fragment is short and drawn from the server's own text (ProjectCloneService: "You already
|
|
166
|
+
// have N working copies of this project, which is the limit (N)"), and it survives the generic
|
|
167
|
+
// handler's "An unexpected error occurred: " prefix. If the server ever rewords it, the hint stops
|
|
168
|
+
// appearing — a false negative, which costs a developer one lookup, rather than a false positive,
|
|
169
|
+
// which costs them a copy.
|
|
170
|
+
const isCapacityRefusal = /working cop(y|ies) of this project/i.test(err.message || "");
|
|
171
|
+
const held = isCapacityRefusal ? copiesOfSourceProject(source.project_id) : [];
|
|
172
|
+
if (held.length) {
|
|
173
|
+
const rows = held.map((h) => ` ${(h.branch || "(unknown branch)").padEnd(28)}${h.folderPath}`);
|
|
174
|
+
// No closing instruction: the server's message already ends with "Run `niro
|
|
175
|
+
// discard-temp-project` in the folder for one you have finished with". Repeating it made the
|
|
176
|
+
// developer read the same sentence twice. This side adds the one thing the server cannot know —
|
|
177
|
+
// where each of those branches is checked out.
|
|
178
|
+
err.message = `${err.message}\n\n Those copies are checked out here:\n${rows.join("\n")}`;
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
151
182
|
const newProjectId = outcome.new_project_id || outcome.newProjectId;
|
|
152
183
|
const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
|
|
153
184
|
const newGitId = gitIdMap[source.git_id];
|
|
@@ -286,6 +317,25 @@ async function takeover(pathArg, opts = {}) {
|
|
|
286
317
|
* field and returns the outcome inline exactly as before, which is the shape this already handled. So
|
|
287
318
|
* there is no capability check to keep in step, and no flag day.
|
|
288
319
|
*/
|
|
320
|
+
/**
|
|
321
|
+
* The folders on THIS machine that hold a working copy of `sourceProjectId`, with the branch each was
|
|
322
|
+
* taken over on.
|
|
323
|
+
*
|
|
324
|
+
* Read-only and local: no server call, no parsing of anybody's message. Used to turn the branches the
|
|
325
|
+
* server names in a capacity refusal into folders the developer can actually go and stand in.
|
|
326
|
+
*
|
|
327
|
+
* Not the same question as the holder guard above, which asks whether a DIFFERENT folder holds the slot
|
|
328
|
+
* for this particular repo and therefore matches on sourceGitId and excludes the current folder. This
|
|
329
|
+
* one wants all of them, including the folder you are standing in — that copy is as discardable as any
|
|
330
|
+
* other, and leaving it out of the list would be confusing when it is the one you had forgotten about.
|
|
331
|
+
*/
|
|
332
|
+
function copiesOfSourceProject(sourceProjectId) {
|
|
333
|
+
return watchState.allLocal(api.currentBaseUrl())
|
|
334
|
+
.filter((e) => e.takeover && e.takeover.sourceProjectId === sourceProjectId)
|
|
335
|
+
.map((e) => ({ folderPath: e.folderPath, branch: e.branch || null }))
|
|
336
|
+
.sort((a, b) => (a.folderPath < b.folderPath ? -1 : 1));
|
|
337
|
+
}
|
|
338
|
+
|
|
289
339
|
async function requestClone(projectId, gitId, branch, opts = {}) {
|
|
290
340
|
const started = await api.post(
|
|
291
341
|
`/api/projects/${encodeURIComponent(projectId)}/clone`,
|
|
@@ -355,4 +405,4 @@ function sleep(ms) {
|
|
|
355
405
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
356
406
|
}
|
|
357
407
|
|
|
358
|
-
module.exports = { takeover, requestClone};
|
|
408
|
+
module.exports = { takeover, requestClone, copiesOfSourceProject };
|
package/src/commands/watch.js
CHANGED
|
@@ -21,6 +21,7 @@ const color = require("../lib/color");
|
|
|
21
21
|
const folderSync = require("../lib/folderSync");
|
|
22
22
|
const git = require("../lib/git");
|
|
23
23
|
const tempProject = require("../lib/tempProject");
|
|
24
|
+
const periodicClaim = require("../lib/periodicClaim");
|
|
24
25
|
const {
|
|
25
26
|
SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
|
|
26
27
|
IGNORED_DIRS, isConfigName, chunk,
|
|
@@ -177,6 +178,8 @@ async function watch(pathArg, opts = {}) {
|
|
|
177
178
|
} catch (err) {
|
|
178
179
|
console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
|
|
179
180
|
}
|
|
181
|
+
startPeriodicSweep();
|
|
182
|
+
|
|
180
183
|
// Keep process alive
|
|
181
184
|
await new Promise(() => {});
|
|
182
185
|
return;
|
|
@@ -186,6 +189,56 @@ async function watch(pathArg, opts = {}) {
|
|
|
186
189
|
await startWatch(targetDir, opts);
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/**
|
|
193
|
+
* How often the daemon sweeps for temporary projects that have gone from the server. Six hours, not
|
|
194
|
+
* six seconds: its job is repairing pointers that go stale over days, not reacting to keystrokes.
|
|
195
|
+
* Every run is a handful of small GETs.
|
|
196
|
+
*/
|
|
197
|
+
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
198
|
+
/**
|
|
199
|
+
* A short delay before the first sweep rather than one at startup. The daemon has a burst of work when
|
|
200
|
+
* it starts (a delta sync per folder), and the sweep is in no hurry — it can wait for that to settle.
|
|
201
|
+
*/
|
|
202
|
+
const SWEEP_STARTUP_DELAY_MS = 60 * 1000;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Periodically run `niro cleanup-temp-projects`. Machine-wide, so it belongs HERE, in the no-path
|
|
206
|
+
* branch, and not in startWatch — startWatch runs once per folder, and this must run once per machine.
|
|
207
|
+
*
|
|
208
|
+
* It runs the sweep in its DEFAULT mode, which only repairs local registrations whose temporary project
|
|
209
|
+
* the server no longer has. It never deletes anything server-side. That is what makes it safe to run
|
|
210
|
+
* unattended: every project it acts on is already gone, so the worst case is a pointer left as it was.
|
|
211
|
+
*
|
|
212
|
+
* Every run is gated by periodicClaim, which persists the last run time. That stops two things that
|
|
213
|
+
* would otherwise both happen: PM2 restarting the daemon (on a crash, a reboot, or
|
|
214
|
+
* `niro service restart`) turning a six-hourly job into a job that runs on every restart, and a
|
|
215
|
+
* `niro watch` left running in a terminal sweeping at the same moment as the PM2 daemon.
|
|
216
|
+
*
|
|
217
|
+
* Never throws: a sweep failure must not touch the daemon's actual job, which is syncing files.
|
|
218
|
+
* Exported so tests can drive it without starting a daemon.
|
|
219
|
+
*/
|
|
220
|
+
function startPeriodicSweep({ intervalMs = SWEEP_INTERVAL_MS, startupDelayMs = SWEEP_STARTUP_DELAY_MS,
|
|
221
|
+
claimName = "tempProjectSweep" } = {}) {
|
|
222
|
+
const run = async () => {
|
|
223
|
+
if (!periodicClaim.claim(claimName, intervalMs)) return;
|
|
224
|
+
try {
|
|
225
|
+
const cleanupCmd = require("./cleanup");
|
|
226
|
+
await cleanupCmd.cleanup({
|
|
227
|
+
yes: true, // nobody is at the terminal to answer a prompt
|
|
228
|
+
quiet: true, // only speak when it actually changed something
|
|
229
|
+
});
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error(color.yellow(` Temporary-project sweep skipped: ${err && err.message}`));
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const first = setTimeout(run, startupDelayMs);
|
|
235
|
+
const timer = setInterval(run, intervalMs);
|
|
236
|
+
// unref so neither timer holds the process open on its own; the daemon is kept alive by its watchers.
|
|
237
|
+
if (first.unref) first.unref();
|
|
238
|
+
if (timer.unref) timer.unref();
|
|
239
|
+
return { stop: () => { clearTimeout(first); clearInterval(timer); } };
|
|
240
|
+
}
|
|
241
|
+
|
|
189
242
|
/**
|
|
190
243
|
* One watch entry failed to start (its startup delta sync threw). Contain the failure to that
|
|
191
244
|
* entry — and when the cause is "the repo no longer exists on this backend" (a poison entry left
|
|
@@ -332,40 +385,55 @@ async function deltaSyncOnResume(gitId, targetDir, state, opts = {}) {
|
|
|
332
385
|
try {
|
|
333
386
|
const quiet = opts.quiet === true; // periodic backstop re-scan runs silently unless it actually syncs
|
|
334
387
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
388
|
+
// Is this folder still in step with the temporary project it uploads to? If my branch and the copy's
|
|
389
|
+
// branch label disagree, uploading writes this branch's files into a copy labelled with another one,
|
|
390
|
+
// and every answer later drawn from that copy is wrong with nothing on screen to say so.
|
|
391
|
+
//
|
|
392
|
+
// Pausing costs the developer nothing while they are out of step, which is the part that is easy to
|
|
393
|
+
// get wrong. ai-assistant will not route a session to a copy whose label is not the caller's branch
|
|
394
|
+
// (`if (branch.equals(repo.getBranch())) return true;`, TempProjectRoutingService.java:358, and the
|
|
395
|
+
// copies are pre-filtered to that branch key at :325). So a drifted copy is already answering nobody.
|
|
396
|
+
// Uploading into it does not restore anything, it only fills it with the wrong branch's files, and
|
|
397
|
+
// nothing in the upload path ever relabels it (zero setBranch calls under service/agent). The fix is
|
|
398
|
+
// for the developer to run `niro new-temp-project`, which is what the message says.
|
|
339
399
|
//
|
|
340
400
|
// Nothing is changed or discarded here. The local record is left exactly as it is, so running
|
|
341
401
|
// `niro new-temp-project` here picks straight back up. This only declines to WRITE.
|
|
342
402
|
if (state.takeover && state.takeover.sourceProjectId) {
|
|
343
403
|
const heldBranch = await tempProject.currentBranch(state.projectId, gitId);
|
|
344
404
|
const myBranch = ((git.inspect(targetDir) || {}).branch) || state.branch;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
405
|
+
|
|
406
|
+
if (!heldBranch || !myBranch) {
|
|
407
|
+
// COULD NOT ASK. This used to fall through to the release below, which both printed "resuming
|
|
408
|
+
// uploads" and let a folder that had genuinely lost its slot start writing again on any network
|
|
409
|
+
// hiccup. It is also where a customer's 306 pauses against 286 resumes came from: nearly equal
|
|
410
|
+
// counts because it was flapping, one warning printed on every re-entry.
|
|
411
|
+
//
|
|
412
|
+
// Not knowing is not news. Whatever was decided last time stands until the server answers again.
|
|
413
|
+
if (notHolding.has(inFlightKey)) return { notHolder: true };
|
|
414
|
+
} else if (heldBranch !== myBranch) {
|
|
348
415
|
if (!notHolding.has(inFlightKey)) {
|
|
349
416
|
notHolding.add(inFlightKey);
|
|
350
|
-
// TWO very different causes
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
`
|
|
417
|
+
// TWO very different causes needing different advice, and this is where the old message was
|
|
418
|
+
// wrong. It looked for another folder on the same PROJECT id, but a project can hold several
|
|
419
|
+
// repos and each folder can have its own, so it named folders that were writing nowhere near
|
|
420
|
+
// this one and told the developer to go and discard them. `gitId` is the repo this pass
|
|
421
|
+
// actually uploads to, so another folder carrying the same gitId is, by definition, writing to
|
|
422
|
+
// the same place. That is the only thing that makes it somebody else's doing rather than yours.
|
|
423
|
+
const rival = watchState.allLocal(api.currentBaseUrl()).find((e) =>
|
|
424
|
+
e.gitId === gitId && path.resolve(e.folderPath) !== inFlightKey);
|
|
425
|
+
console.warn(color.yellow(rival
|
|
426
|
+
? ` ${targetDir} and ${rival.folderPath} are both uploading to the same temporary project. ` +
|
|
427
|
+
`This folder is on \`${myBranch}\`, the copy is on \`${heldBranch}\`. Pausing uploads from ` +
|
|
428
|
+
`here so the two branches do not mix. Run \`niro new-temp-project\` here to give this folder ` +
|
|
429
|
+
`a copy of its own, or \`niro discard-temp-project\` in the other folder.`
|
|
430
|
+
: ` ${targetDir} is on \`${myBranch}\`, but its temporary project is still labelled ` +
|
|
431
|
+
`\`${heldBranch}\`. You switched branch in this folder. Your sessions will not use this copy ` +
|
|
432
|
+
`until the two agree, so uploads are paused rather than filling it with the wrong branch. ` +
|
|
433
|
+
`Run \`niro new-temp-project\` here to move it onto \`${myBranch}\`.`));
|
|
365
434
|
}
|
|
366
435
|
return { notHolder: true };
|
|
367
|
-
}
|
|
368
|
-
if (notHolding.delete(inFlightKey)) {
|
|
436
|
+
} else if (notHolding.delete(inFlightKey)) {
|
|
369
437
|
console.log(color.green(` ${targetDir} holds the temporary project again — resuming uploads.`));
|
|
370
438
|
}
|
|
371
439
|
}
|
|
@@ -877,7 +945,7 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
|
|
|
877
945
|
|
|
878
946
|
// handleFileEvent + deltaSyncOnResume are exported for unit testing the
|
|
879
947
|
// gitignore/fail-closed guards without a live server or fs.watch loop.
|
|
880
|
-
module.exports = { watch, startWatch, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
|
|
948
|
+
module.exports = { watch, startWatch, startPeriodicSweep, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
|
|
881
949
|
queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
|
|
882
950
|
unreadableRoots,
|
|
883
951
|
nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
|
package/src/index.js
CHANGED
|
@@ -249,7 +249,28 @@ function main() {
|
|
|
249
249
|
await cmd.release(pathArg, { ...opts });
|
|
250
250
|
}));
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
// `niro doctor` — checks that compare this machine against the server and repair what is safe to
|
|
253
|
+
// repair. A group from the start, because there is more than one of these coming; `sync-registry` is
|
|
254
|
+
// simply the first. Published commands (`status`, `info`, `service status`) deliberately stay where
|
|
255
|
+
// they are: they shipped in 0.10.1 and that surface is stable.
|
|
256
|
+
const doctor = program
|
|
257
|
+
.command("doctor")
|
|
258
|
+
.description("Check this machine against the server and repair what is safe to repair.");
|
|
259
|
+
|
|
260
|
+
const syncRegistry = (opts) => run(async () => {
|
|
261
|
+
const cmd = require("./commands/cleanup");
|
|
262
|
+
await cmd.cleanup({ ...opts });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
doctor
|
|
266
|
+
.command("sync-registry", { isDefault: true })
|
|
267
|
+
.description("Bring this machine's record of your projects back in line with the server: folders pointing at projects that are gone, and .niro-project files naming something unknown.")
|
|
268
|
+
.option("--dry-run", "list what would change and change nothing")
|
|
269
|
+
.option("--forget <path>", "drop one folder's registration (for a folder you have deleted); the folder itself is never touched")
|
|
270
|
+
.option("-y, --yes", "skip the confirmation prompt")
|
|
271
|
+
.option("--json", "emit machine-readable JSON")
|
|
272
|
+
.action(syncRegistry);
|
|
273
|
+
program
|
|
253
274
|
.command("status [project_id]")
|
|
254
275
|
.description("Show the current build status for a project.")
|
|
255
276
|
.option("--json", "emit machine-readable JSON")
|
|
@@ -372,8 +393,7 @@ function run(fn) {
|
|
|
372
393
|
console.error(err.message);
|
|
373
394
|
process.exit(1);
|
|
374
395
|
}
|
|
375
|
-
|
|
376
|
-
if (err && err.status) console.error(` (HTTP ${err.status})`);
|
|
396
|
+
require("./lib/printError").printError(err);
|
|
377
397
|
process.exit(1);
|
|
378
398
|
});
|
|
379
399
|
}
|
package/src/lib/folderSync.js
CHANGED
|
@@ -69,6 +69,12 @@ const STRUCTURAL_FILENAMES = new Set([
|
|
|
69
69
|
"project.json", "directory.build.props", "next.config.js", "next.config.mjs",
|
|
70
70
|
"next.config.ts", "vite.config.ts", "vite.config.js", "setupproxy.js",
|
|
71
71
|
"setupproxy.ts",
|
|
72
|
+
// Java servlet deployment descriptor. A pre-Spring-Boot web app declares its whole
|
|
73
|
+
// HTTP surface here rather than in annotations, so without this the server sees a
|
|
74
|
+
// servlet application with zero endpoints. Measured on one customer repo: 11 real
|
|
75
|
+
// endpoints, 0 detected. Read server-side by WebXmlServletRouteReader.
|
|
76
|
+
// MIRROR: ai-orchestrator SourceFileFilterService.STRUCTURAL_FILENAMES.
|
|
77
|
+
"web.xml",
|
|
72
78
|
]);
|
|
73
79
|
const STRUCTURAL_EXTENSIONS = new Set([
|
|
74
80
|
".csproj", ".fsproj", ".sln", ".gemspec", ".rockspec", ".nimble", ".opam",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Has this periodic job already run recently, and may I be the one to run it now?"
|
|
3
|
+
*
|
|
4
|
+
* Two problems, one answer.
|
|
5
|
+
*
|
|
6
|
+
* PM2 restarts the watch daemon on a crash, on a reboot, and on `niro service restart`. A job wired to
|
|
7
|
+
* a plain setInterval that also runs at startup therefore runs on every restart, and a crash loop turns
|
|
8
|
+
* a daily job into a hot loop. So the last run time has to OUTLIVE the process, which means a file.
|
|
9
|
+
*
|
|
10
|
+
* And two daemons can be up at once — the PM2 one plus a `niro watch` somebody left running in a
|
|
11
|
+
* terminal. Both would fire the same job at the same moment. For a job that only reads that is waste;
|
|
12
|
+
* for one that deletes on the server it is two processes racing over the same decision.
|
|
13
|
+
*
|
|
14
|
+
* A timestamp file answers both: whoever writes it first owns the interval, and everybody else sees a
|
|
15
|
+
* recent stamp and stands down. The write is atomic (tmp + rename, the same technique watchState uses)
|
|
16
|
+
* so a reader never sees a half-written file.
|
|
17
|
+
*
|
|
18
|
+
* NOT a mutex. Two processes calling claim() in the same millisecond can both win: each reads the old
|
|
19
|
+
* stamp before either writes. Closing that needs an O_EXCL lock file plus stale-lock recovery, which
|
|
20
|
+
* costs more than it saves here — the jobs this guards are idempotent (a second sweep finds the work
|
|
21
|
+
* already done) and the interval is hours, so the collision window is milliseconds wide once a day.
|
|
22
|
+
* The comment is here so nobody later mistakes this for a lock and puts something non-idempotent
|
|
23
|
+
* behind it.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const os = require("os");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
|
|
29
|
+
const DIR = path.join(os.homedir(), ".niro");
|
|
30
|
+
const FILE = path.join(DIR, "periodic.json");
|
|
31
|
+
|
|
32
|
+
function load() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(FILE, "utf8"));
|
|
35
|
+
// An ARRAY passes `typeof === "object"`, and JSON.stringify then drops the named property we set on
|
|
36
|
+
// it — so the stamp never persisted and the job claimed on EVERY tick, which is precisely the crash
|
|
37
|
+
// loop this module exists to prevent. Anything that is not a plain object starts again from empty.
|
|
38
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
39
|
+
return parsed;
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function save(state) {
|
|
46
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
47
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
48
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
49
|
+
fs.renameSync(tmp, FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** When `name` last ran, in epoch millis, or null if it never has (or the stamp is unreadable). */
|
|
53
|
+
/** Said once per process, not once per tick — a six-hourly job would otherwise fill the log. */
|
|
54
|
+
let warnedAboutWrite = false;
|
|
55
|
+
|
|
56
|
+
function lastRun(name) {
|
|
57
|
+
const at = load()[name];
|
|
58
|
+
const n = Number(at);
|
|
59
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Try to claim the current interval for `name`.
|
|
64
|
+
*
|
|
65
|
+
* Returns true and stamps the file when `intervalMs` has passed since the last run (or it has never
|
|
66
|
+
* run). Returns false and writes nothing otherwise.
|
|
67
|
+
*
|
|
68
|
+
* The stamp is written BEFORE the job runs, not after. A job that dies half way through must not be
|
|
69
|
+
* retried immediately on the next tick — that is the crash loop this exists to prevent — and these
|
|
70
|
+
* jobs are safe to skip for one interval. The cost of the choice is that a run interrupted at 1% waits
|
|
71
|
+
* a full interval to try again, which is the right trade for something that runs every few hours.
|
|
72
|
+
*
|
|
73
|
+
* A stamp in the FUTURE (a clock that jumped back, or a file copied between machines) would otherwise
|
|
74
|
+
* block the job until real time caught up, potentially for years. Treated as "never ran" instead.
|
|
75
|
+
*/
|
|
76
|
+
function claim(name, intervalMs, now = Date.now()) {
|
|
77
|
+
const last = lastRun(name);
|
|
78
|
+
if (last !== null && last <= now && now - last < intervalMs) return false;
|
|
79
|
+
const state = load();
|
|
80
|
+
state[name] = now;
|
|
81
|
+
try {
|
|
82
|
+
save(state);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Cannot persist the stamp (read-only home, a file left by a `sudo` run, a full disk). Refuse the
|
|
85
|
+
// claim rather than run: an unstampable job would run on EVERY tick, which is worse than not
|
|
86
|
+
// running at all.
|
|
87
|
+
//
|
|
88
|
+
// But SAY SO, once. Returning a quiet false disables the job for good, and the developer has no
|
|
89
|
+
// way to know: their folders simply stop being tidied and nothing ever mentions it.
|
|
90
|
+
if (!warnedAboutWrite) {
|
|
91
|
+
warnedAboutWrite = true;
|
|
92
|
+
console.warn(` Could not write ${FILE} (${err.message}). Periodic housekeeping is paused; ` +
|
|
93
|
+
"fix the permissions on ~/.niro to re-enable it.");
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { claim, lastRun, FILE };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a failed command is written to the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.js's `run` for one reason: it was dropping text the user needed, and there was
|
|
5
|
+
* no way to test that. index.js calls main() at load, so nothing could require it.
|
|
6
|
+
*
|
|
7
|
+
* The bug it exists to stop: the printer took `err.stack.split("\n")[0]`, which is the error's class
|
|
8
|
+
* name plus the FIRST LINE of its message. Any message built over several lines therefore printed its
|
|
9
|
+
* opening line and silently swallowed the rest. Two real cases, both of which end in the user staring
|
|
10
|
+
* at a sentence that promises a list and then shows nothing:
|
|
11
|
+
*
|
|
12
|
+
* "Multiple projects match this repo — pass --project:" ...and then no projects.
|
|
13
|
+
* "You already have 5 working copies..." ...and then no folders to go and look in.
|
|
14
|
+
*
|
|
15
|
+
* Nothing was broken in either feature. The text existed, was correct, and never reached the screen.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Print `err` the way the CLI always has, plus the lines that were being lost.
|
|
20
|
+
*
|
|
21
|
+
* The first line still comes from the stack, so an error's real class name survives (a TypeError says
|
|
22
|
+
* TypeError, not Error). Subsequent lines come from the MESSAGE, never the stack, because a stack trace
|
|
23
|
+
* is noise to somebody running a CLI and this repo deliberately does not print them.
|
|
24
|
+
*
|
|
25
|
+
* A single-line message prints exactly as it did before, byte for byte.
|
|
26
|
+
*
|
|
27
|
+
* @param {*} err the thrown value; need not be an Error
|
|
28
|
+
* @param {(...args: any[]) => void} write where to send each line (console.error by default)
|
|
29
|
+
*/
|
|
30
|
+
function printError(err, write = console.error) {
|
|
31
|
+
// With a stack, its first line already carries the class name and the message's first line, so only
|
|
32
|
+
// the REMAINING message lines are added. Without one — anything thrown that is not an Error — there
|
|
33
|
+
// is no such line, so the whole message is printed rather than silently starting at line two.
|
|
34
|
+
const hasStack = Boolean(err && err.stack);
|
|
35
|
+
const first = hasStack ? String(err.stack).split("\n")[0] : String(err);
|
|
36
|
+
write(first);
|
|
37
|
+
// Only lines the FIRST line did not already carry. With a stack that is everything after line one.
|
|
38
|
+
// Without one, String(err) may already be the whole message (an Error whose stack was stripped
|
|
39
|
+
// stringifies to "Error: <the lot>"), so printing from line one again said it all twice.
|
|
40
|
+
const messageLines = err && typeof err.message === "string" ? err.message.split("\n") : [];
|
|
41
|
+
const rest = hasStack ? messageLines.slice(1) : messageLines.filter((l) => !first.includes(l));
|
|
42
|
+
for (const line of rest) write(line);
|
|
43
|
+
if (err && err.status) write(` (HTTP ${err.status})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { printError };
|