toolroll 0.9.82 → 0.9.83
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 +14 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +107 -0
- package/dist/folder-look.js +302 -0
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/operate.d.ts +32 -0
- package/dist/operate.js +186 -28
- package/dist/project-identity.d.ts +37 -0
- package/dist/project-identity.js +106 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +35 -8
- package/dist/server/flows.js +9 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.js +3 -0
- package/dist/server/pages.js +62 -88
- package/dist/server/render-pages.d.ts +2 -16
- package/dist/server/render-pages.js +1 -55
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.js +14 -3
- package/dist/server/tasks.js +7 -3
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -110,7 +110,7 @@ import { claimInstance, clearInstance, openInstanceSocket } from "./console-inst
|
|
|
110
110
|
import { authorizePlanUnderMode } from "./plan-auto.js";
|
|
111
111
|
import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
|
|
112
112
|
import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
|
|
113
|
-
import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
|
|
113
|
+
import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry, registryRemovals } from "./repos.js";
|
|
114
114
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
115
115
|
import { pushPass } from "./push.js";
|
|
116
116
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
|
|
@@ -181,6 +181,8 @@ import { createDecisionServer } from "./serve.js";
|
|
|
181
181
|
import { readTailnetNames } from "./tailnet.js";
|
|
182
182
|
import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
|
|
183
183
|
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
184
|
+
import { projectIdentities } from "./project-identity.js";
|
|
185
|
+
import { lookIdentities } from "./folder-look.js";
|
|
184
186
|
import { scanRepo } from "./capscan.js";
|
|
185
187
|
import { computeGaps, describeCapability } from "./gaps.js";
|
|
186
188
|
import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
|
|
@@ -3939,7 +3941,13 @@ async function startConsole(options) {
|
|
|
3939
3941
|
const { context } = options;
|
|
3940
3942
|
// A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
|
|
3941
3943
|
noticeGitIncidents(context.store, context.clock);
|
|
3944
|
+
// Every project folder this console starts with is resolved off its thread, under a deadline: one that doesn't
|
|
3945
|
+
// answer (a drive away, an iCloud folder macOS is holding) stays unresolved, kept restrictive, and is looked at again
|
|
3946
|
+
// by the background pass; the console starts and answers either way (project-identity.ts).
|
|
3947
|
+
const identities = projectIdentities();
|
|
3948
|
+
await identities.refresh([...options.repos ?? [], ...options.projectRoots ?? [], ...context.store.listProjects().map(one => one.path), ...context.store.knownRepos()]);
|
|
3942
3949
|
const server = createDecisionServer({
|
|
3950
|
+
identities,
|
|
3943
3951
|
store: context.store,
|
|
3944
3952
|
// Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
|
|
3945
3953
|
backgroundChecks: true,
|
|
@@ -7055,6 +7063,8 @@ async function upCommand(flags, context) {
|
|
|
7055
7063
|
// folders they may be added from are installation state, not arguments a
|
|
7056
7064
|
// person must repeat on every start.
|
|
7057
7065
|
const registryPath = registryPathOf(context);
|
|
7066
|
+
// Read before the list itself: a line removed after this (even if saved again) counts differently from now on.
|
|
7067
|
+
const removalsAtStart = registryRemovals(registryPath);
|
|
7058
7068
|
const loadedRegistry = await loadProjectRegistry(registryPath);
|
|
7059
7069
|
if ("error" in loadedRegistry)
|
|
7060
7070
|
return fail(write, json, "up", "registry", loadedRegistry.error, EXIT.refused);
|
|
@@ -7078,23 +7088,28 @@ async function upCommand(flags, context) {
|
|
|
7078
7088
|
// soon as a projects folder or saved repository exists.
|
|
7079
7089
|
const repos = [];
|
|
7080
7090
|
const proveRepo = async (input) => {
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
try {
|
|
7085
|
-
return realpathSync(top.stdout.trim());
|
|
7086
|
-
}
|
|
7087
|
-
catch {
|
|
7088
|
-
return null;
|
|
7089
|
-
}
|
|
7091
|
+
// The checkout's real path is read off the thread, under a deadline (folder-look.ts): a folder that doesn't answer
|
|
7092
|
+
// is unavailable, never a wait.
|
|
7093
|
+
return (await upProveSaved(gitRun, input))?.root ?? null;
|
|
7090
7094
|
};
|
|
7095
|
+
// A saved project that doesn't answer now (a drive not connected, a folder macOS is holding) is looked at again
|
|
7096
|
+
// while this start runs, and connects once it answers (upDeferredRetry).
|
|
7097
|
+
const deferredRepos = [];
|
|
7098
|
+
const deferredGenerations = {};
|
|
7099
|
+
// The saved line(s) each project was proved from, so its removal is judged by its own line alone.
|
|
7100
|
+
const repoLines = {};
|
|
7091
7101
|
for (const input of loadedRegistry.repos) {
|
|
7092
7102
|
const root = await proveRepo(input);
|
|
7093
7103
|
if (root === null) {
|
|
7094
|
-
progress(`saved project unavailable — skipped ${input}`);
|
|
7104
|
+
progress(`saved project unavailable — skipped ${input}; it connects when it's back`);
|
|
7105
|
+
deferredRepos.push(input);
|
|
7106
|
+
// An unreadable removal record leaves no count that could match later: the authority can't be used.
|
|
7107
|
+
deferredGenerations[input] = removalsAtStart === null ? -1 : removalsAtStart[input] ?? 0;
|
|
7095
7108
|
}
|
|
7096
|
-
else
|
|
7097
|
-
repos.
|
|
7109
|
+
else {
|
|
7110
|
+
if (!repos.includes(root))
|
|
7111
|
+
repos.push(root);
|
|
7112
|
+
(repoLines[root] ??= []).push(input);
|
|
7098
7113
|
}
|
|
7099
7114
|
}
|
|
7100
7115
|
const explicitRepos = context.repoList ?? [];
|
|
@@ -7118,7 +7133,9 @@ async function upCommand(flags, context) {
|
|
|
7118
7133
|
if (cwdRepo !== null)
|
|
7119
7134
|
pinnedRepos.add(cwdRepo);
|
|
7120
7135
|
}
|
|
7121
|
-
|
|
7136
|
+
// Saved projects that are only away for now still start the service: it serves an empty, restrictive set until they
|
|
7137
|
+
// answer (never the unscoped mode), and connects each as it comes back.
|
|
7138
|
+
if (repos.length === 0 && projectRoots.length === 0 && deferredRepos.length === 0)
|
|
7122
7139
|
return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
|
|
7123
7140
|
// A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
|
|
7124
7141
|
const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
|
|
@@ -7376,6 +7393,9 @@ async function upCommand(flags, context) {
|
|
|
7376
7393
|
type: "start", role: "worker", databaseFile: context.databaseFile, containment: containmentFlag, build,
|
|
7377
7394
|
config: {
|
|
7378
7395
|
runner: runnerName, token: runnerToken, repos: generation === 1 ? [...repos] : [...currentRepos], pinnedRepos: [...pinnedRepos], projectRoots, registryPath, pool,
|
|
7396
|
+
// What this start still holds for its saved projects that were away: a restarted worker never gets back an
|
|
7397
|
+
// authority an earlier one gave up (connected, or removed from the saved list).
|
|
7398
|
+
deferredRepos: [...deferredRepos], deferredGenerations: { ...deferredGenerations }, repoLines,
|
|
7379
7399
|
forMs: forGiven === undefined ? null : Number(forGiven), publicUrl: publicUrlFlag ?? null, stopGraceMs, verbose: flags.has("verbose"),
|
|
7380
7400
|
terminal: context.upSeams?.terminal ?? process.stdout.isTTY === true,
|
|
7381
7401
|
// A restart waits out a crashed predecessor's lease (at most a minute), then recovers it: as a fresh `up` after a crash.
|
|
@@ -7385,6 +7405,12 @@ async function upCommand(flags, context) {
|
|
|
7385
7405
|
onMessage: message => {
|
|
7386
7406
|
if (message.type === "log")
|
|
7387
7407
|
roleLine("worker")(message.line);
|
|
7408
|
+
else if (message.type === "deferred") {
|
|
7409
|
+
// Only ever narrows: a worker's word can't give back an authority this start no longer holds.
|
|
7410
|
+
const narrowed = upNarrowDeferred(deferredRepos, message.repos);
|
|
7411
|
+
deferredRepos.length = 0;
|
|
7412
|
+
deferredRepos.push(...narrowed);
|
|
7413
|
+
}
|
|
7388
7414
|
else if (message.type === "repos") {
|
|
7389
7415
|
// The console's admitted projects widen only to what the builder is bound to in the store (the worker binds
|
|
7390
7416
|
// through the authenticated door before it reports): a worker's word alone admits nothing.
|
|
@@ -12093,15 +12119,9 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12093
12119
|
const pinnedRepos = new Set(config.pinnedRepos);
|
|
12094
12120
|
const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
|
|
12095
12121
|
const proveRepo = async (input) => {
|
|
12096
|
-
|
|
12097
|
-
|
|
12098
|
-
|
|
12099
|
-
try {
|
|
12100
|
-
return realpathSync(top.stdout.trim());
|
|
12101
|
-
}
|
|
12102
|
-
catch {
|
|
12103
|
-
return null;
|
|
12104
|
-
}
|
|
12122
|
+
// The checkout's real path is read off the thread, under a deadline (folder-look.ts): a folder that doesn't answer
|
|
12123
|
+
// is unavailable, never a wait.
|
|
12124
|
+
return (await upProveSaved(gitRun, input))?.root ?? null;
|
|
12105
12125
|
};
|
|
12106
12126
|
// The proved set this worker builds; `up` relays each change to the console.
|
|
12107
12127
|
const activeRepos = new Set(config.repos);
|
|
@@ -12286,8 +12306,17 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12286
12306
|
await pause(follower === null ? 2_000 : 5_000);
|
|
12287
12307
|
}
|
|
12288
12308
|
})();
|
|
12289
|
-
|
|
12309
|
+
// The start's own projects and folders were resolved by `up`: taken as they are, never read again on this thread.
|
|
12310
|
+
const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots, path => resolve(path)).ceiling;
|
|
12290
12311
|
const rejected = new Set();
|
|
12312
|
+
// Saved lines' identities, resolved off the thread under a deadline (project-identity.ts): a line whose folder
|
|
12313
|
+
// doesn't answer is skipped this pass, and never holds up the others.
|
|
12314
|
+
const lineIdentities = projectIdentities();
|
|
12315
|
+
/** The saved line(s) each active project came from: `up`'s proof for the start's own, the line admitted for later ones. */
|
|
12316
|
+
const admittedLines = new Map(Object.entries(config.repoLines ?? {}));
|
|
12317
|
+
const deferred = upDeferredRetry(config.deferredRepos ?? [], input => upProveSaved(gitRun, input));
|
|
12318
|
+
const deferredGenerations = config.deferredGenerations ?? {};
|
|
12319
|
+
const reportDeferred = () => port.send({ type: "deferred", repos: deferred.list() });
|
|
12291
12320
|
const registrySupervisor = (async () => {
|
|
12292
12321
|
const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
|
|
12293
12322
|
while (!stopping) {
|
|
@@ -12297,11 +12326,19 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12297
12326
|
stop();
|
|
12298
12327
|
break;
|
|
12299
12328
|
}
|
|
12329
|
+
// A saved line removed while this start runs takes the start's authority for it with it, for good.
|
|
12330
|
+
if (deferred.keepOnly(loaded.repos))
|
|
12331
|
+
reportDeferred();
|
|
12332
|
+
const plain = loaded.repos.filter(one => !deferred.has(one));
|
|
12333
|
+
await lineIdentities.refresh(plain);
|
|
12300
12334
|
// Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
|
|
12301
|
-
|
|
12335
|
+
// Each project is judged by its own saved line (the one it was proved or admitted from, or itself), never by
|
|
12336
|
+
// whether some other folder answered: an unrelated folder that is away never keeps a removed project building.
|
|
12337
|
+
const saved = new Set(loaded.repos);
|
|
12302
12338
|
let removed = false;
|
|
12303
12339
|
for (const repo of [...activeRepos]) {
|
|
12304
|
-
|
|
12340
|
+
const lines = admittedLines.get(repo) ?? [repo];
|
|
12341
|
+
if (stopping || pinnedRepos.has(repo) || saved.has(repo) || lines.some(line => saved.has(line)))
|
|
12305
12342
|
continue;
|
|
12306
12343
|
activeRepos.delete(repo);
|
|
12307
12344
|
removed = true;
|
|
@@ -12314,12 +12351,23 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12314
12351
|
for (const candidate of loaded.repos) {
|
|
12315
12352
|
if (stopping)
|
|
12316
12353
|
break;
|
|
12317
|
-
|
|
12354
|
+
// Skipped at start because it didn't answer: connected on the start's own authority once it does.
|
|
12355
|
+
if (deferred.has(candidate)) {
|
|
12356
|
+
await connectDeferred(candidate);
|
|
12357
|
+
continue;
|
|
12358
|
+
}
|
|
12359
|
+
const canonical = lineIdentities.known(candidate);
|
|
12360
|
+
// Not answered yet: looked at again on the next pass.
|
|
12361
|
+
if (canonical === undefined)
|
|
12362
|
+
continue;
|
|
12318
12363
|
// Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
|
|
12319
12364
|
if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
|
|
12320
12365
|
continue;
|
|
12321
12366
|
const approvedAdditions = context.additionalProjectRepos?.() ?? [];
|
|
12322
|
-
|
|
12367
|
+
// A start serving nothing yet (its saved projects all away) is an empty ceiling, never the unscoped mode: a
|
|
12368
|
+
// name that matches no path stands in, so a line saved later still needs its recorded admission.
|
|
12369
|
+
const admittedRepos = [...dynamicCeiling.repos, ...approvedAdditions];
|
|
12370
|
+
const admittedCeiling = { repos: admittedRepos.length === 0 && dynamicCeiling.roots.length === 0 ? ["/\0nothing-admitted"] : admittedRepos, roots: dynamicCeiling.roots, canon: (path) => lineIdentities.known(path) ?? null };
|
|
12323
12371
|
// A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
|
|
12324
12372
|
// registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
|
|
12325
12373
|
const allowed = canonical !== null &&
|
|
@@ -12333,6 +12381,7 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12333
12381
|
continue;
|
|
12334
12382
|
}
|
|
12335
12383
|
rejected.delete(candidate);
|
|
12384
|
+
admittedLines.set(canonical, [...new Set([...admittedLines.get(canonical) ?? [], candidate])]);
|
|
12336
12385
|
await launchRepo(canonical, true);
|
|
12337
12386
|
}
|
|
12338
12387
|
if (!stopping)
|
|
@@ -12342,6 +12391,61 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12342
12391
|
fatal = `the project registry stopped (${describe(error)})`;
|
|
12343
12392
|
stop();
|
|
12344
12393
|
});
|
|
12394
|
+
/**
|
|
12395
|
+
* A saved project the start couldn't reach, back now: connected only as the very folder that was saved (a parent
|
|
12396
|
+
* checkout found because the saved one lost its .git is another project, needing its own admission), and only while
|
|
12397
|
+
* the line is still saved, checked under the saved list's lock with the binding made inside it, so a removal racing
|
|
12398
|
+
* the proof always wins.
|
|
12399
|
+
*/
|
|
12400
|
+
async function connectDeferred(candidate) {
|
|
12401
|
+
const proved = await deferred.retry(candidate);
|
|
12402
|
+
if (proved === null || stopping)
|
|
12403
|
+
return;
|
|
12404
|
+
// Only the very folder that was saved: the saved line must still resolve to itself, and git's checkout must be that
|
|
12405
|
+
// folder. A parent checkout, or the saved path now leading somewhere else, is another project needing admission.
|
|
12406
|
+
if (proved.line !== candidate || proved.root !== candidate) {
|
|
12407
|
+
deferred.revoke(candidate);
|
|
12408
|
+
reportDeferred();
|
|
12409
|
+
progress(proved.root !== proved.line
|
|
12410
|
+
? `saved project not connected — ${candidate} came back as part of ${proved.root}; add it again to connect it`
|
|
12411
|
+
: `saved project not connected — ${candidate} now leads to ${proved.line}; add it again to connect it`);
|
|
12412
|
+
return;
|
|
12413
|
+
}
|
|
12414
|
+
const root = candidate;
|
|
12415
|
+
if (activeRepos.has(root) || releasedRepos.has(root)) {
|
|
12416
|
+
deferred.revoke(candidate);
|
|
12417
|
+
reportDeferred();
|
|
12418
|
+
return;
|
|
12419
|
+
}
|
|
12420
|
+
// Bound under the saved list's lock: the line is still saved AND has not been removed since this start read it
|
|
12421
|
+
// (its removal count unchanged, so a removal and a save again during the proof still counts as removed).
|
|
12422
|
+
let launching = null;
|
|
12423
|
+
let removedSince = false;
|
|
12424
|
+
await updateProjectRegistry(registryPath, current => {
|
|
12425
|
+
if (stopping || !deferred.has(candidate))
|
|
12426
|
+
return current;
|
|
12427
|
+
const count = registryRemovals(registryPath)?.[candidate] ?? (registryRemovals(registryPath) === null ? -2 : 0);
|
|
12428
|
+
if (!current.repos.includes(candidate) || count !== deferredGenerations[candidate]) {
|
|
12429
|
+
deferred.revoke(candidate);
|
|
12430
|
+
removedSince = true;
|
|
12431
|
+
return current;
|
|
12432
|
+
}
|
|
12433
|
+
deferred.revoke(candidate);
|
|
12434
|
+
admittedLines.set(root, [candidate]);
|
|
12435
|
+
launching = launchRepo(root, true);
|
|
12436
|
+
return current;
|
|
12437
|
+
});
|
|
12438
|
+
if (removedSince) {
|
|
12439
|
+
reportDeferred();
|
|
12440
|
+
progress(`saved project not connected — ${candidate} was removed while it was away; add it again to connect it`);
|
|
12441
|
+
return;
|
|
12442
|
+
}
|
|
12443
|
+
if (launching === null)
|
|
12444
|
+
return;
|
|
12445
|
+
reportDeferred();
|
|
12446
|
+
progress(`saved project is back — connecting ${root}`);
|
|
12447
|
+
await launching;
|
|
12448
|
+
}
|
|
12345
12449
|
// Provider readiness (v47): this machine's non-spending observations, recorded under its own runner name ONCE at
|
|
12346
12450
|
// start — so every route projection can say ready / unavailable / unknown per provider, and the dispatch gate can
|
|
12347
12451
|
// halt a leg this machine cannot run. Production only: a test's injected agent runner is a fake, and its readiness
|
|
@@ -12530,3 +12634,57 @@ async function evalsAbandonCommand(flags, context) {
|
|
|
12530
12634
|
return fail(write, json, command, ended.reason, ended.message, EXIT.refused);
|
|
12531
12635
|
return succeed(write, json, command, { campaign: id, state: "incomplete", attempts: ended.attempts, spending: "unknown" }, () => [`Campaign ${id} ended incomplete. ${ended.attempts} unfinished attempt${ended.attempts === 1 ? "" : "s"} recorded as errors; what it spent is unknown. Nothing was stopped: check for its processes yourself.`]);
|
|
12532
12636
|
}
|
|
12637
|
+
/**
|
|
12638
|
+
* The saved projects an `up` start couldn't reach (upCommand): each is proved again every `everyMs` while the worker
|
|
12639
|
+
* runs, by `prove` (upProveSaved), so a folder that answers again connects without a restart. Only what the start itself
|
|
12640
|
+
* read from the saved list is here, and the set only shrinks: a line connected, removed from the saved list
|
|
12641
|
+
* (`keepOnly`) or come back as some other checkout (`revoke`) is gone for good, so a line saved again later needs its
|
|
12642
|
+
* recorded admission like any other addition. `retry` answers the proof when one is due and answered, else null.
|
|
12643
|
+
*/
|
|
12644
|
+
export function upDeferredRetry(inputs, prove, everyMs = 5_000, now = Date.now) {
|
|
12645
|
+
// Wall time, not the store's clock (a test's may stand still): this is only how often a folder is asked.
|
|
12646
|
+
const nextAt = new Map(inputs.map(input => [input, now() + everyMs]));
|
|
12647
|
+
return {
|
|
12648
|
+
has: input => nextAt.has(input),
|
|
12649
|
+
list: () => [...nextAt.keys()],
|
|
12650
|
+
retry: async (input) => {
|
|
12651
|
+
const due = nextAt.get(input);
|
|
12652
|
+
if (due === undefined || now() < due)
|
|
12653
|
+
return null;
|
|
12654
|
+
nextAt.set(input, now() + everyMs);
|
|
12655
|
+
return prove(input);
|
|
12656
|
+
},
|
|
12657
|
+
revoke: input => { nextAt.delete(input); },
|
|
12658
|
+
keepOnly: saved => {
|
|
12659
|
+
const keep = new Set(saved);
|
|
12660
|
+
let changed = false;
|
|
12661
|
+
for (const input of [...nextAt.keys()])
|
|
12662
|
+
if (!keep.has(input)) {
|
|
12663
|
+
nextAt.delete(input);
|
|
12664
|
+
changed = true;
|
|
12665
|
+
}
|
|
12666
|
+
return changed;
|
|
12667
|
+
},
|
|
12668
|
+
};
|
|
12669
|
+
}
|
|
12670
|
+
/**
|
|
12671
|
+
* Whether a saved project's folder is a Git checkout now, read without blocking this thread: `git rev-parse
|
|
12672
|
+
* --show-toplevel` (bounded, its own process), then the real paths of the saved line and of the checkout, resolved off
|
|
12673
|
+
* the thread under a deadline (folder-look.ts). Null when it isn't one, or didn't answer.
|
|
12674
|
+
*/
|
|
12675
|
+
export async function upProveSaved(gitRun, input, look = {}) {
|
|
12676
|
+
const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
|
|
12677
|
+
if (top.code !== 0)
|
|
12678
|
+
return null;
|
|
12679
|
+
const answered = await lookIdentities([resolve(input), top.stdout.trim()], look);
|
|
12680
|
+
if (!answered.ok)
|
|
12681
|
+
return null;
|
|
12682
|
+
const line = answered.value.get(resolve(input)) ?? null, root = answered.value.get(top.stdout.trim()) ?? null;
|
|
12683
|
+
return line === null || root === null ? null : { line, root };
|
|
12684
|
+
}
|
|
12685
|
+
/** What `up` hands its next worker after the worker reports which saved projects it still holds start authority for:
|
|
12686
|
+
* only what both still hold. A worker's report can give up an authority, never add or give back one. */
|
|
12687
|
+
export function upNarrowDeferred(held, reported) {
|
|
12688
|
+
const kept = new Set(reported);
|
|
12689
|
+
return held.filter(one => kept.has(one));
|
|
12690
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project identities as the console uses them, without touching a person's folders on its thread.
|
|
3
|
+
*
|
|
4
|
+
* A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
|
|
5
|
+
* stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
|
|
6
|
+
*
|
|
7
|
+
* - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
|
|
8
|
+
* - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
|
|
9
|
+
* refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
|
|
10
|
+
* - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
|
|
11
|
+
* Anything else is refused this time and resolved by the next background pass, never resolved inline.
|
|
12
|
+
* - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
|
|
13
|
+
* - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
|
|
14
|
+
*
|
|
15
|
+
* An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
|
|
16
|
+
*/
|
|
17
|
+
import { type FolderLookOptions } from "./folder-look.js";
|
|
18
|
+
export type ProjectIdentities = {
|
|
19
|
+
/** For display and grouping only: the last known identity, else the path as saved (resolved lexically). Reads
|
|
20
|
+
* nothing. Never an authorization answer: use `strict`. */
|
|
21
|
+
hot: (path: string) => string;
|
|
22
|
+
/** For authorization: the known real folder path, else null (unknown or not a folder: fail closed until resolved).
|
|
23
|
+
* Reads nothing; an unknown path is asked about on the next refresh. */
|
|
24
|
+
strict: (path: string) => string | null;
|
|
25
|
+
/** The known identity: a real folder path, null (not a folder), or undefined (never answered). Reads nothing. */
|
|
26
|
+
known: (path: string) => string | null | undefined;
|
|
27
|
+
/** A request's path: its known identity, or the path itself when it is exactly one of `saved` (projects the plane
|
|
28
|
+
* itself proved and admitted); else undefined. */
|
|
29
|
+
vouched: (path: string, saved: () => Iterable<string>) => string | undefined;
|
|
30
|
+
/** This path's identity now, off the thread: a real folder path, null, or "no-answer" past the deadline. */
|
|
31
|
+
resolveNow: (path: string) => Promise<string | null | "no-answer">;
|
|
32
|
+
/** Resolve `paths`, everything asked about since, and answers older than a minute. False when some didn't answer. */
|
|
33
|
+
refresh: (paths: Iterable<string>) => Promise<boolean>;
|
|
34
|
+
};
|
|
35
|
+
export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
|
|
36
|
+
/** The synchronous reads, as before: for embedders and tests that pass no identities. */
|
|
37
|
+
export declare function syncProjectIdentities(): ProjectIdentities;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project identities as the console uses them, without touching a person's folders on its thread.
|
|
3
|
+
*
|
|
4
|
+
* A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
|
|
5
|
+
* stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread:
|
|
6
|
+
*
|
|
7
|
+
* - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
|
|
8
|
+
* - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
|
|
9
|
+
* refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
|
|
10
|
+
* - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
|
|
11
|
+
* Anything else is refused this time and resolved by the next background pass, never resolved inline.
|
|
12
|
+
* - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
|
|
13
|
+
* - `refresh` (the console's start and its background pass): resolves what was asked about and what has gone stale.
|
|
14
|
+
*
|
|
15
|
+
* An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
|
|
16
|
+
*/
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { lookIdentities } from "./folder-look.js";
|
|
19
|
+
import { canonicalProject } from "./project.js";
|
|
20
|
+
/** How long an answer stands before the background pass resolves the path again. */
|
|
21
|
+
const STALE_MS = 60_000;
|
|
22
|
+
export function projectIdentities(look = {}, now = Date.now) {
|
|
23
|
+
const answers = new Map();
|
|
24
|
+
const wanted = new Set();
|
|
25
|
+
const known = (path) => answers.get(resolve(path))?.real;
|
|
26
|
+
const store = (found) => {
|
|
27
|
+
const at = now();
|
|
28
|
+
for (const [path, real] of found) {
|
|
29
|
+
answers.set(resolve(path), { real, at });
|
|
30
|
+
wanted.delete(resolve(path));
|
|
31
|
+
// A real path is its own identity.
|
|
32
|
+
if (real !== null)
|
|
33
|
+
answers.set(real, { real, at });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const resolveNow = async (path) => {
|
|
37
|
+
const looked = await lookIdentities([resolve(path)], look);
|
|
38
|
+
if (!looked.ok)
|
|
39
|
+
return "no-answer";
|
|
40
|
+
store(looked.value);
|
|
41
|
+
return looked.value.get(resolve(path)) ?? null;
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
hot: path => {
|
|
45
|
+
const key = resolve(path);
|
|
46
|
+
const answer = answers.get(key);
|
|
47
|
+
if (answer === undefined)
|
|
48
|
+
wanted.add(key);
|
|
49
|
+
return answer?.real ?? key;
|
|
50
|
+
},
|
|
51
|
+
known,
|
|
52
|
+
strict: path => {
|
|
53
|
+
const key = resolve(path);
|
|
54
|
+
const answer = answers.get(key);
|
|
55
|
+
if (answer === undefined)
|
|
56
|
+
wanted.add(key);
|
|
57
|
+
return answer?.real ?? null;
|
|
58
|
+
},
|
|
59
|
+
vouched: (path, saved) => {
|
|
60
|
+
const key = resolve(path);
|
|
61
|
+
const answer = answers.get(key);
|
|
62
|
+
if (answer !== undefined && answer.real !== null)
|
|
63
|
+
return answer.real;
|
|
64
|
+
for (const one of saved())
|
|
65
|
+
if (one === key)
|
|
66
|
+
return key;
|
|
67
|
+
wanted.add(key);
|
|
68
|
+
return undefined;
|
|
69
|
+
},
|
|
70
|
+
resolveNow,
|
|
71
|
+
refresh: async (paths) => {
|
|
72
|
+
const at = now();
|
|
73
|
+
const due = new Set(wanted);
|
|
74
|
+
for (const one of paths) {
|
|
75
|
+
const key = resolve(one);
|
|
76
|
+
const answer = answers.get(key);
|
|
77
|
+
if (answer === undefined || at - answer.at >= STALE_MS)
|
|
78
|
+
due.add(key);
|
|
79
|
+
}
|
|
80
|
+
for (const [key, answer] of answers)
|
|
81
|
+
if (at - answer.at >= STALE_MS)
|
|
82
|
+
due.add(key);
|
|
83
|
+
if (due.size === 0)
|
|
84
|
+
return true;
|
|
85
|
+
const batch = await lookIdentities([...due], look);
|
|
86
|
+
if (batch.ok) {
|
|
87
|
+
store(batch.value);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
// One folder that doesn't answer mustn't hold the others: each on its own, in parallel (the looks are capped).
|
|
91
|
+
const each = await Promise.all([...due].map(one => resolveNow(one)));
|
|
92
|
+
return each.every(one => one !== "no-answer");
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** The synchronous reads, as before: for embedders and tests that pass no identities. */
|
|
97
|
+
export function syncProjectIdentities() {
|
|
98
|
+
return {
|
|
99
|
+
hot: path => canonicalProject(path) ?? path,
|
|
100
|
+
known: path => canonicalProject(path),
|
|
101
|
+
strict: path => canonicalProject(path),
|
|
102
|
+
vouched: path => canonicalProject(path) ?? undefined,
|
|
103
|
+
resolveNow: async (path) => canonicalProject(path),
|
|
104
|
+
refresh: async () => true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -22,19 +22,17 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
|
|
|
22
22
|
export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
|
-
title?: never;
|
|
26
|
-
content?: never;
|
|
27
25
|
history?: never;
|
|
28
26
|
revision: number;
|
|
29
27
|
stale: boolean;
|
|
30
28
|
notice: string;
|
|
31
29
|
decision?: never;
|
|
30
|
+
title?: never;
|
|
31
|
+
content?: never;
|
|
32
32
|
instructions?: never;
|
|
33
33
|
references?: never;
|
|
34
34
|
decisions?: never;
|
|
35
35
|
} | {
|
|
36
|
-
title?: never;
|
|
37
|
-
content?: never;
|
|
38
36
|
history?: never;
|
|
39
37
|
stale?: never;
|
|
40
38
|
decision: {
|
|
@@ -52,6 +50,8 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
52
50
|
id: number;
|
|
53
51
|
};
|
|
54
52
|
notice: string;
|
|
53
|
+
title?: never;
|
|
54
|
+
content?: never;
|
|
55
55
|
revision?: never;
|
|
56
56
|
instructions?: never;
|
|
57
57
|
references?: never;
|
|
@@ -68,10 +68,10 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
68
68
|
references?: never;
|
|
69
69
|
decisions?: never;
|
|
70
70
|
} | {
|
|
71
|
-
title?: never;
|
|
72
|
-
content?: never;
|
|
73
71
|
stale?: never;
|
|
74
72
|
decision?: never;
|
|
73
|
+
title?: never;
|
|
74
|
+
content?: never;
|
|
75
75
|
revision: number;
|
|
76
76
|
history: {
|
|
77
77
|
revision: number;
|
package/dist/project-tools.d.ts
CHANGED
|
@@ -103,7 +103,7 @@ export type FoundTool = {
|
|
|
103
103
|
* Codex's (from `codex mcp list --json`, passed in). The first of each
|
|
104
104
|
* name wins; unusable entries are left out.
|
|
105
105
|
*/
|
|
106
|
-
export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv): FoundTool[];
|
|
106
|
+
export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv, projectConfig?: Record<string, unknown> | null): FoundTool[];
|
|
107
107
|
/** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
|
|
108
108
|
export declare function testTool(spec: ToolSpec, values: Record<string, string>, options: {
|
|
109
109
|
timeoutMs?: number;
|
|
@@ -313,3 +313,6 @@ export declare function toolServerFor(store: ToolServerStore, repo: string, opti
|
|
|
313
313
|
home?: string;
|
|
314
314
|
available?: () => boolean;
|
|
315
315
|
}): ToolServer;
|
|
316
|
+
/** Each project's `.mcp.json` as the console last read it off its thread (its background pass): what its pages and the
|
|
317
|
+
* lead offer, so neither reads a project folder on the console's thread. */
|
|
318
|
+
export declare const savedProjectMcp: Map<string, Record<string, unknown> | null>;
|
package/dist/project-tools.js
CHANGED
|
@@ -496,7 +496,7 @@ function fromCodexEntry(raw, environment) {
|
|
|
496
496
|
* Codex's (from `codex mcp list --json`, passed in). The first of each
|
|
497
497
|
* name wins; unusable entries are left out.
|
|
498
498
|
*/
|
|
499
|
-
export function discoverTools(repo, codexServers, home = homedir(), environment = process.env) {
|
|
499
|
+
export function discoverTools(repo, codexServers, home = homedir(), environment = process.env, projectConfig) {
|
|
500
500
|
const found = [];
|
|
501
501
|
const add = (one) => { if (one !== null && !found.some(other => other.spec.name === one.spec.name))
|
|
502
502
|
found.push(one); };
|
|
@@ -509,7 +509,9 @@ export function discoverTools(repo, codexServers, home = homedir(), environment
|
|
|
509
509
|
return null;
|
|
510
510
|
}
|
|
511
511
|
};
|
|
512
|
-
|
|
512
|
+
// The project's own file as given (the console reads it off its thread: folder-look.ts), else as last read there
|
|
513
|
+
// (savedProjectMcp), else read here (the CLI and the worker).
|
|
514
|
+
const project = projectConfig !== undefined ? projectConfig : savedProjectMcp.has(repo) ? savedProjectMcp.get(repo) ?? null : readJson(join(repo, ".mcp.json"));
|
|
513
515
|
for (const [name, raw] of Object.entries((project?.["mcpServers"] ?? {})))
|
|
514
516
|
add(fromClaudeEntry(name, raw, "this project's .mcp.json"));
|
|
515
517
|
const claude = readJson(join(home, ".claude.json"));
|
|
@@ -1149,3 +1151,6 @@ export function toolServerFor(store, repo, options = {}) {
|
|
|
1149
1151
|
const untrusted = existsSync(cache) ? [realpathSync(cache)] : [];
|
|
1150
1152
|
return { fence: fenceFor("tool-server", { databaseFile, worktree: null, home, boundary: null, binding: null, runFolders: runFolders(null), poolRoots, extra: untrusted }) };
|
|
1151
1153
|
}
|
|
1154
|
+
/** Each project's `.mcp.json` as the console last read it off its thread (its background pass): what its pages and the
|
|
1155
|
+
* lead offer, so neither reads a project folder on the console's thread. */
|
|
1156
|
+
export const savedProjectMcp = new Map();
|
package/dist/project.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export type ProjectCeiling = {
|
|
|
29
29
|
repos: readonly string[];
|
|
30
30
|
/** Canonical directories: any git repo under one is authorized. */
|
|
31
31
|
roots: readonly string[];
|
|
32
|
+
/** How this ceiling resolves a path it is asked about (project-identity.ts): the console's saved identities, which
|
|
33
|
+
* read no folder. Absent: canonicalProject, a synchronous read. */
|
|
34
|
+
canon?: (path: string) => string | null;
|
|
32
35
|
};
|
|
33
36
|
/**
|
|
34
37
|
* Resolve the startup configuration once. A configured path that does not
|
|
@@ -36,7 +39,7 @@ export type ProjectCeiling = {
|
|
|
36
39
|
* dropping would silently *widen* the server toward unscoped mode, and a
|
|
37
40
|
* ceiling that loosens itself when a disk is unmounted is not a ceiling.
|
|
38
41
|
*/
|
|
39
|
-
export declare function resolveCeiling(repos: readonly string[], roots: readonly string[]): {
|
|
42
|
+
export declare function resolveCeiling(repos: readonly string[], roots: readonly string[], canon?: (path: string) => string | null | undefined): {
|
|
40
43
|
ceiling: ProjectCeiling;
|
|
41
44
|
unresolved: string[];
|
|
42
45
|
};
|
package/dist/project.js
CHANGED
|
@@ -67,10 +67,10 @@ export async function projectSelection(paths, exec = run) {
|
|
|
67
67
|
* dropping would silently *widen* the server toward unscoped mode, and a
|
|
68
68
|
* ceiling that loosens itself when a disk is unmounted is not a ceiling.
|
|
69
69
|
*/
|
|
70
|
-
export function resolveCeiling(repos, roots) {
|
|
70
|
+
export function resolveCeiling(repos, roots, canon = canonicalProject) {
|
|
71
71
|
const unresolved = [];
|
|
72
72
|
const keep = (paths) => [...new Set(paths.map(path => {
|
|
73
|
-
const canonical =
|
|
73
|
+
const canonical = canon(path) ?? null;
|
|
74
74
|
if (canonical !== null)
|
|
75
75
|
return canonical;
|
|
76
76
|
unresolved.push(path);
|
|
@@ -90,7 +90,7 @@ export function unscoped(ceiling) {
|
|
|
90
90
|
export async function authorizedProject(ceiling, path, exec = run) {
|
|
91
91
|
if (unscoped(ceiling))
|
|
92
92
|
return true;
|
|
93
|
-
const canonical = canonicalProject(path);
|
|
93
|
+
const canonical = (ceiling.canon ?? canonicalProject)(path);
|
|
94
94
|
if (canonical === null)
|
|
95
95
|
return false;
|
|
96
96
|
if (ceiling.repos.includes(canonical))
|
|
@@ -114,8 +114,15 @@ export function rowVisible(ceiling, repo) {
|
|
|
114
114
|
return true;
|
|
115
115
|
// Exact saved identities are common. Check the whole set before resolving
|
|
116
116
|
// aliases, so the hundredth project does not stat the other99 directories.
|
|
117
|
-
|
|
117
|
+
const canon = ceiling.canon;
|
|
118
|
+
if (ceiling.repos.includes(repo) || ceiling.repos.some(one => canon === undefined ? sameRepo(one, repo) : (canon(one) ?? null) !== null && canon(one) === canon(repo)))
|
|
118
119
|
return true;
|
|
120
|
+
// With a resolver (the console's saved identities), an identity it can't vouch for fails closed before any root is
|
|
121
|
+
// matched: a path's spelling never stands in for where it leads.
|
|
122
|
+
if (canon !== undefined) {
|
|
123
|
+
const resolved = canon(repo);
|
|
124
|
+
return resolved !== null && ceiling.roots.some(root => resolved.startsWith(root + sep));
|
|
125
|
+
}
|
|
119
126
|
const canonical = canonicalProject(repo) ?? repo;
|
|
120
127
|
return ceiling.roots.some(root => canonical.startsWith(root + sep));
|
|
121
128
|
}
|
package/dist/repos.d.ts
CHANGED
|
@@ -57,4 +57,11 @@ export declare function updateProjectRegistry(file: string, transform: (registry
|
|
|
57
57
|
} | RegistryUpdateFailure>;
|
|
58
58
|
export declare function addRepos(existing: readonly string[], incoming: readonly string[]): string[];
|
|
59
59
|
export declare function removeRepos(existing: readonly string[], targets: readonly string[]): string[];
|
|
60
|
+
/** Beside the saved list: how many times each line has been removed from it (written under its lock). */
|
|
61
|
+
export declare function registryRemovalsFile(file: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
|
|
64
|
+
* as one answers null: its caller treats every line as changed (fail closed).
|
|
65
|
+
*/
|
|
66
|
+
export declare function registryRemovals(file: string): Record<string, number> | null;
|
|
60
67
|
export {};
|