toolroll 0.9.81 → 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.
Files changed (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli.js +1 -0
  3. package/dist/commit-extract.d.ts +18 -0
  4. package/dist/commit-extract.js +39 -1
  5. package/dist/converse.d.ts +12 -0
  6. package/dist/converse.js +18 -2
  7. package/dist/deploy-gate.js +8 -0
  8. package/dist/desktop-update.d.ts +1 -1
  9. package/dist/eval-gate.d.ts +51 -0
  10. package/dist/eval-gate.js +199 -0
  11. package/dist/eval-hooks.d.ts +39 -0
  12. package/dist/eval-hooks.js +477 -0
  13. package/dist/eval-oracle-main.d.ts +1 -0
  14. package/dist/eval-oracle-main.js +31 -0
  15. package/dist/eval-oracle.d.ts +60 -0
  16. package/dist/eval-oracle.js +217 -0
  17. package/dist/eval-world.d.ts +17 -0
  18. package/dist/eval-world.js +97 -0
  19. package/dist/evals.d.ts +299 -0
  20. package/dist/evals.js +964 -0
  21. package/dist/exec.js +15 -1
  22. package/dist/first-tasks-child.d.ts +1 -0
  23. package/dist/first-tasks-child.js +11 -0
  24. package/dist/flow-draft.js +4 -0
  25. package/dist/flow-sort.js +4 -0
  26. package/dist/folder-look.d.ts +107 -0
  27. package/dist/folder-look.js +302 -0
  28. package/dist/github-jobs.d.ts +25 -0
  29. package/dist/github-jobs.js +22 -0
  30. package/dist/invoke.d.ts +1 -1
  31. package/dist/invoke.js +14 -0
  32. package/dist/lead.js +4 -1
  33. package/dist/learning-child.d.ts +1 -0
  34. package/dist/learning-child.js +34 -0
  35. package/dist/learning-look.d.ts +25 -0
  36. package/dist/learning-look.js +72 -0
  37. package/dist/memory-pass.js +4 -0
  38. package/dist/nightly-check.d.ts +4 -1
  39. package/dist/nightly-check.js +5 -2
  40. package/dist/nightly-github.d.ts +316 -0
  41. package/dist/nightly-github.js +1022 -0
  42. package/dist/operate.d.ts +123 -1
  43. package/dist/operate.js +344 -29
  44. package/dist/project-identity.d.ts +37 -0
  45. package/dist/project-identity.js +106 -0
  46. package/dist/project-knowledge.d.ts +4 -4
  47. package/dist/project-tools.d.ts +4 -1
  48. package/dist/project-tools.js +7 -2
  49. package/dist/project.d.ts +4 -1
  50. package/dist/project.js +11 -4
  51. package/dist/provider.d.ts +3 -0
  52. package/dist/provider.js +9 -2
  53. package/dist/release-adapters.d.ts +21 -21
  54. package/dist/release-adapters.js +52 -19
  55. package/dist/release-auto.js +26 -0
  56. package/dist/release-card.js +3 -0
  57. package/dist/release-cli.js +59 -3
  58. package/dist/release-view.d.ts +3 -0
  59. package/dist/release-view.js +2 -0
  60. package/dist/release.d.ts +16 -0
  61. package/dist/release.js +16 -0
  62. package/dist/reported-usage.d.ts +69 -0
  63. package/dist/reported-usage.js +97 -0
  64. package/dist/repos.d.ts +7 -0
  65. package/dist/repos.js +57 -0
  66. package/dist/serve.js +35 -8
  67. package/dist/server/chat.js +4 -0
  68. package/dist/server/flows.js +9 -3
  69. package/dist/server/guards.d.ts +4 -0
  70. package/dist/server/guards.js +12 -3
  71. package/dist/server/http.d.ts +11 -0
  72. package/dist/server/http.js +3 -0
  73. package/dist/server/pages.js +62 -88
  74. package/dist/server/render-pages.d.ts +2 -16
  75. package/dist/server/render-pages.js +1 -55
  76. package/dist/server/render-tasks.js +1 -0
  77. package/dist/server/runtime.d.ts +4 -0
  78. package/dist/server/runtime.js +1 -0
  79. package/dist/server/settings.js +14 -3
  80. package/dist/server/tasks.js +15 -4
  81. package/dist/store.d.ts +109 -1
  82. package/dist/store.js +394 -4
  83. package/dist/subagents.js +4 -0
  84. package/dist/subscription-chat.d.ts +2 -0
  85. package/dist/subscription-chat.js +52 -13
  86. package/dist/task-sizing.js +4 -0
  87. package/dist/teardown-git.d.ts +9 -2
  88. package/dist/teardown-git.js +56 -33
  89. package/dist/up-processes.d.ts +13 -0
  90. package/dist/up-processes.js +6 -2
  91. package/evals/cases/catalog/case.json +25 -0
  92. package/evals/cases/catalog/fixture/README.md +3 -0
  93. package/evals/cases/catalog/fixture/package.json +8 -0
  94. package/evals/cases/catalog/fixture/test/catalog.test.mjs +4 -0
  95. package/evals/cases/catalog/oracle.mjs +23 -0
  96. package/evals/cases/catalog/probe.mjs +10 -0
  97. package/evals/cases/catalog/source.md +9 -0
  98. package/evals/cases/csv-quoting/case.json +23 -0
  99. package/evals/cases/csv-quoting/fixture/README.md +3 -0
  100. package/evals/cases/csv-quoting/fixture/csv.js +1 -0
  101. package/evals/cases/csv-quoting/fixture/package.json +8 -0
  102. package/evals/cases/csv-quoting/fixture/test/csv.test.mjs +5 -0
  103. package/evals/cases/csv-quoting/oracle.mjs +14 -0
  104. package/evals/cases/csv-quoting/probe.mjs +9 -0
  105. package/evals/cases/csv-quoting/source.md +9 -0
  106. package/evals/cases/dedupe/case.json +23 -0
  107. package/evals/cases/dedupe/fixture/README.md +3 -0
  108. package/evals/cases/dedupe/fixture/dedupe.js +1 -0
  109. package/evals/cases/dedupe/fixture/package.json +8 -0
  110. package/evals/cases/dedupe/fixture/test/dedupe.test.mjs +5 -0
  111. package/evals/cases/dedupe/oracle.mjs +16 -0
  112. package/evals/cases/dedupe/probe.mjs +13 -0
  113. package/evals/cases/dedupe/source.md +9 -0
  114. package/evals/cases/endpoint/case.json +25 -0
  115. package/evals/cases/endpoint/fixture/README.md +3 -0
  116. package/evals/cases/endpoint/fixture/package.json +8 -0
  117. package/evals/cases/endpoint/fixture/test/endpoint.test.mjs +4 -0
  118. package/evals/cases/endpoint/oracle.mjs +27 -0
  119. package/evals/cases/endpoint/probe.mjs +10 -0
  120. package/evals/cases/endpoint/source.md +11 -0
  121. package/evals/cases/math-divide/case.json +23 -0
  122. package/evals/cases/math-divide/fixture/README.md +3 -0
  123. package/evals/cases/math-divide/fixture/package.json +8 -0
  124. package/evals/cases/math-divide/fixture/src/math.js +1 -0
  125. package/evals/cases/math-divide/fixture/test/math.test.js +5 -0
  126. package/evals/cases/math-divide/oracle.mjs +13 -0
  127. package/evals/cases/math-divide/probe.mjs +9 -0
  128. package/evals/cases/math-divide/source.md +9 -0
  129. package/evals/cases/math-multiply/case.json +23 -0
  130. package/evals/cases/math-multiply/fixture/README.md +3 -0
  131. package/evals/cases/math-multiply/fixture/package.json +8 -0
  132. package/evals/cases/math-multiply/fixture/src/math.js +2 -0
  133. package/evals/cases/math-multiply/fixture/test/math.test.js +5 -0
  134. package/evals/cases/math-multiply/oracle.mjs +12 -0
  135. package/evals/cases/math-multiply/probe.mjs +9 -0
  136. package/evals/cases/math-multiply/source.md +9 -0
  137. package/evals/cases/math-subtract/case.json +23 -0
  138. package/evals/cases/math-subtract/fixture/README.md +3 -0
  139. package/evals/cases/math-subtract/fixture/package.json +8 -0
  140. package/evals/cases/math-subtract/fixture/src/math.js +1 -0
  141. package/evals/cases/math-subtract/fixture/test/math.test.js +5 -0
  142. package/evals/cases/math-subtract/oracle.mjs +12 -0
  143. package/evals/cases/math-subtract/probe.mjs +9 -0
  144. package/evals/cases/math-subtract/source.md +9 -0
  145. package/evals/cases/merge-config/case.json +23 -0
  146. package/evals/cases/merge-config/fixture/README.md +3 -0
  147. package/evals/cases/merge-config/fixture/config.js +1 -0
  148. package/evals/cases/merge-config/fixture/package.json +8 -0
  149. package/evals/cases/merge-config/fixture/test/config.test.mjs +5 -0
  150. package/evals/cases/merge-config/oracle.mjs +28 -0
  151. package/evals/cases/merge-config/probe.mjs +15 -0
  152. package/evals/cases/merge-config/source.md +9 -0
  153. package/evals/cases/paginate/case.json +23 -0
  154. package/evals/cases/paginate/fixture/README.md +3 -0
  155. package/evals/cases/paginate/fixture/package.json +8 -0
  156. package/evals/cases/paginate/fixture/paginate.js +1 -0
  157. package/evals/cases/paginate/fixture/test/paginate.test.mjs +5 -0
  158. package/evals/cases/paginate/oracle.mjs +18 -0
  159. package/evals/cases/paginate/probe.mjs +13 -0
  160. package/evals/cases/paginate/source.md +9 -0
  161. package/evals/cases/round2-asks/case.json +24 -0
  162. package/evals/cases/round2-asks/fixture/README.md +3 -0
  163. package/evals/cases/round2-asks/fixture/package.json +8 -0
  164. package/evals/cases/round2-asks/fixture/src/math.js +1 -0
  165. package/evals/cases/round2-asks/fixture/test/math.test.js +5 -0
  166. package/evals/cases/round2-asks/source.md +11 -0
  167. package/evals/manifest.json +15 -0
  168. package/package.json +2 -1
package/dist/operate.js CHANGED
@@ -60,6 +60,9 @@ import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
60
60
  import { refreshConnections } from "./mcp-connect.js";
61
61
  import { releaseCheckPurposeFor, releaseTermsFor } from "./release-carry.js";
62
62
  import { refreshTrustedMains, settleReleaseChecks } from "./nightly-check.js";
63
+ import { githubNightlyRepos, readGithubNightlies } from "./nightly-github.js";
64
+ import { exec as releaseExec, githubReads } from "./release-adapters.js";
65
+ import { releaseTools } from "./release-git.js";
63
66
  import { SECRETS_FIRST, startToolSecrets, toolSecretStorage } from "./project-tools.js";
64
67
  import { followDiscord } from "./discord.js";
65
68
  import { followTeams } from "./teams.js";
@@ -99,7 +102,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
99
102
  * **Nothing ever prompts.** There is no terminal on the other end at 3am.
100
103
  */
101
104
  import { homedir, hostname } from "node:os";
102
- import { basename, dirname, join, resolve } from "node:path";
105
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
103
106
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
104
107
  import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
105
108
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
@@ -107,7 +110,7 @@ import { claimInstance, clearInstance, openInstanceSocket } from "./console-inst
107
110
  import { authorizePlanUnderMode } from "./plan-auto.js";
108
111
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
109
112
  import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
110
- import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
113
+ import { addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry, registryRemovals } from "./repos.js";
111
114
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
112
115
  import { pushPass } from "./push.js";
113
116
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
@@ -178,6 +181,8 @@ import { createDecisionServer } from "./serve.js";
178
181
  import { readTailnetNames } from "./tailnet.js";
179
182
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
180
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";
181
186
  import { scanRepo } from "./capscan.js";
182
187
  import { computeGaps, describeCapability } from "./gaps.js";
183
188
  import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
@@ -247,6 +252,7 @@ import { installationStatus, renderInstallationStatus, renderTaskWait, repairSta
247
252
  import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
248
253
  import { installMethod } from "./install-method.js";
249
254
  import { PACKAGE_VERSION } from "./version.js";
255
+ import { machineActing } from "./scope.js";
250
256
  /**
251
257
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
252
258
  *
@@ -676,6 +682,9 @@ export const OPERATE_VALUE_FLAGS = new Set([
676
682
  "choose", "note", "max-open-decisions", "name", "days", "publication", "auto-approve", "review-auto", "port", "host", "allow-host",
677
683
  "for", "tick-every", "bridge-every", "reconcile-every", "incarnation",
678
684
  "say", "ceiling-usd", "cap-usd",
685
+ // evals: the cases a diagnostic campaign runs, its k, an attempt's world and what its builds may not read, and the
686
+ // stuck campaign a person ends (recover --abandon).
687
+ "case", "k", "world", "deny", "abandon",
679
688
  "token-file", "bin", "poll", "github", "remote", "head-prefix", "password",
680
689
  // v102: project rules.
681
690
  "not-requester", "protect", "protect-paths",
@@ -714,6 +723,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
714
723
  "release",
715
724
  ]);
716
725
  export const OPERATE_BOOLEAN_FLAGS = new Set([
726
+ // evals run: a report-only campaign; keep each attempt's world for a look afterwards.
727
+ "diagnostic", "keep",
717
728
  // task scope --release ... --no-carry: every later commit asks again.
718
729
  "no-carry",
719
730
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
@@ -1147,6 +1158,8 @@ async function dispatch(command, positional, flags, context) {
1147
1158
  switch (command) {
1148
1159
  case "status":
1149
1160
  return statusCommand(positional, flags, context);
1161
+ case "evals":
1162
+ return evalsCommand(positional, flags, context);
1150
1163
  case "ready":
1151
1164
  return readyCommand(flags, context);
1152
1165
  case "task":
@@ -3928,7 +3941,13 @@ async function startConsole(options) {
3928
3941
  const { context } = options;
3929
3942
  // A git incident (teardown-git.ts) raises an attention notification in this console's outbox.
3930
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()]);
3931
3949
  const server = createDecisionServer({
3950
+ identities,
3932
3951
  store: context.store,
3933
3952
  // Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
3934
3953
  backgroundChecks: true,
@@ -6279,6 +6298,10 @@ async function runWatchLoop(args) {
6279
6298
  const settledChecks = settleReleaseChecks(store, context.evidenceRoot, context.clock());
6280
6299
  if (settledChecks > 0)
6281
6300
  progress(`watch: settled ${settledChecks} release check(s)`);
6301
+ // GitHub's nightly as release debt (nightly-github.ts): each opted-in project at most every 30 minutes, 60 s in
6302
+ // all. A test's injected agent runner means a test: no real gh.
6303
+ if (context.agentRunner === undefined)
6304
+ await refreshGithubNightlies(store, context.clock);
6282
6305
  // A finished check may send a build's card down its failure path: now, not on the next beat.
6283
6306
  if (ran + batched > 0)
6284
6307
  moveCards(store, repo, context.clock(), context.evidenceRoot);
@@ -7040,6 +7063,8 @@ async function upCommand(flags, context) {
7040
7063
  // folders they may be added from are installation state, not arguments a
7041
7064
  // person must repeat on every start.
7042
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);
7043
7068
  const loadedRegistry = await loadProjectRegistry(registryPath);
7044
7069
  if ("error" in loadedRegistry)
7045
7070
  return fail(write, json, "up", "registry", loadedRegistry.error, EXIT.refused);
@@ -7063,23 +7088,28 @@ async function upCommand(flags, context) {
7063
7088
  // soon as a projects folder or saved repository exists.
7064
7089
  const repos = [];
7065
7090
  const proveRepo = async (input) => {
7066
- const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
7067
- if (top.code !== 0)
7068
- return null;
7069
- try {
7070
- return realpathSync(top.stdout.trim());
7071
- }
7072
- catch {
7073
- return null;
7074
- }
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;
7075
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 = {};
7076
7101
  for (const input of loadedRegistry.repos) {
7077
7102
  const root = await proveRepo(input);
7078
7103
  if (root === null) {
7079
- 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;
7080
7108
  }
7081
- else if (!repos.includes(root)) {
7082
- repos.push(root);
7109
+ else {
7110
+ if (!repos.includes(root))
7111
+ repos.push(root);
7112
+ (repoLines[root] ??= []).push(input);
7083
7113
  }
7084
7114
  }
7085
7115
  const explicitRepos = context.repoList ?? [];
@@ -7103,7 +7133,9 @@ async function upCommand(flags, context) {
7103
7133
  if (cwdRepo !== null)
7104
7134
  pinnedRepos.add(cwdRepo);
7105
7135
  }
7106
- if (repos.length === 0 && projectRoots.length === 0)
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)
7107
7139
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
7108
7140
  // A person at a terminal reads three lines (onboarding); the folders are for --verbose, a service log, or an agent.
7109
7141
  const quietStart = !json && !flags.has("verbose") && (context.upSeams?.terminal ?? process.stdout.isTTY === true) && !underAgent(context.upSeams?.env ?? process.env);
@@ -7361,6 +7393,9 @@ async function upCommand(flags, context) {
7361
7393
  type: "start", role: "worker", databaseFile: context.databaseFile, containment: containmentFlag, build,
7362
7394
  config: {
7363
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,
7364
7399
  forMs: forGiven === undefined ? null : Number(forGiven), publicUrl: publicUrlFlag ?? null, stopGraceMs, verbose: flags.has("verbose"),
7365
7400
  terminal: context.upSeams?.terminal ?? process.stdout.isTTY === true,
7366
7401
  // A restart waits out a crashed predecessor's lease (at most a minute), then recovers it: as a fresh `up` after a crash.
@@ -7370,6 +7405,12 @@ async function upCommand(flags, context) {
7370
7405
  onMessage: message => {
7371
7406
  if (message.type === "log")
7372
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
+ }
7373
7414
  else if (message.type === "repos") {
7374
7415
  // The console's admitted projects widen only to what the builder is bound to in the store (the worker binds
7375
7416
  // through the authenticated door before it reports): a worker's word alone admits nothing.
@@ -12078,15 +12119,9 @@ async function runUpWorkerRole(context, config, port, build) {
12078
12119
  const pinnedRepos = new Set(config.pinnedRepos);
12079
12120
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
12080
12121
  const proveRepo = async (input) => {
12081
- const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd: resolve(input) });
12082
- if (top.code !== 0)
12083
- return null;
12084
- try {
12085
- return realpathSync(top.stdout.trim());
12086
- }
12087
- catch {
12088
- return null;
12089
- }
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;
12090
12125
  };
12091
12126
  // The proved set this worker builds; `up` relays each change to the console.
12092
12127
  const activeRepos = new Set(config.repos);
@@ -12271,8 +12306,17 @@ async function runUpWorkerRole(context, config, port, build) {
12271
12306
  await pause(follower === null ? 2_000 : 5_000);
12272
12307
  }
12273
12308
  })();
12274
- const dynamicCeiling = resolveCeiling(config.repos, config.projectRoots).ceiling;
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;
12275
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() });
12276
12320
  const registrySupervisor = (async () => {
12277
12321
  const sleep = (ms) => new Promise(resolveSleep => setTimeout(resolveSleep, ms));
12278
12322
  while (!stopping) {
@@ -12282,11 +12326,19 @@ async function runUpWorkerRole(context, config, port, build) {
12282
12326
  stop();
12283
12327
  break;
12284
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);
12285
12334
  // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
12286
- const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
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);
12287
12338
  let removed = false;
12288
12339
  for (const repo of [...activeRepos]) {
12289
- if (stopping || pinnedRepos.has(repo) || saved.has(repo))
12340
+ const lines = admittedLines.get(repo) ?? [repo];
12341
+ if (stopping || pinnedRepos.has(repo) || saved.has(repo) || lines.some(line => saved.has(line)))
12290
12342
  continue;
12291
12343
  activeRepos.delete(repo);
12292
12344
  removed = true;
@@ -12299,12 +12351,23 @@ async function runUpWorkerRole(context, config, port, build) {
12299
12351
  for (const candidate of loaded.repos) {
12300
12352
  if (stopping)
12301
12353
  break;
12302
- const canonical = canonicalProject(candidate);
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;
12303
12363
  // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
12304
12364
  if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
12305
12365
  continue;
12306
12366
  const approvedAdditions = context.additionalProjectRepos?.() ?? [];
12307
- const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
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 };
12308
12371
  // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
12309
12372
  // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
12310
12373
  const allowed = canonical !== null &&
@@ -12318,6 +12381,7 @@ async function runUpWorkerRole(context, config, port, build) {
12318
12381
  continue;
12319
12382
  }
12320
12383
  rejected.delete(candidate);
12384
+ admittedLines.set(canonical, [...new Set([...admittedLines.get(canonical) ?? [], candidate])]);
12321
12385
  await launchRepo(canonical, true);
12322
12386
  }
12323
12387
  if (!stopping)
@@ -12327,6 +12391,61 @@ async function runUpWorkerRole(context, config, port, build) {
12327
12391
  fatal = `the project registry stopped (${describe(error)})`;
12328
12392
  stop();
12329
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
+ }
12330
12449
  // Provider readiness (v47): this machine's non-spending observations, recorded under its own runner name ONCE at
12331
12450
  // start — so every route projection can say ready / unavailable / unknown per provider, and the dispatch gate can
12332
12451
  // halt a leg this machine cannot run. Production only: a test's injected agent runner is a fake, and its readiness
@@ -12373,3 +12492,199 @@ async function runUpWorkerRole(context, config, port, build) {
12373
12492
  }
12374
12493
  return EXIT.ok;
12375
12494
  }
12495
+ /** GitHub's nightly as release debt (docs/plans/evals-nightly.md, part A): the watch loop's background read of each
12496
+ * opted-in project's nightly runs, at most every 30 minutes, within a 60 s overall deadline, through the plane's own gh
12497
+ * (release-adapters.ts githubReads). Nothing is read for a project that never opted in. */
12498
+ export async function refreshGithubNightlies(store, now, reads) {
12499
+ if (githubNightlyRepos(store).length === 0)
12500
+ return 0;
12501
+ return readGithubNightlies(store, { gh: reads ?? githubReads(releaseExec, releaseTools().gh, { retry: true }), now });
12502
+ }
12503
+ /**
12504
+ * `toolroll evals run | attempt | cases | recover` (docs/plans/evals-nightly.md, part C).
12505
+ *
12506
+ * - `run [--diagnostic] [--case a,b] [--k n] --as <you> --token <password>`: admit a campaign (a person's proof, the
12507
+ * no release busy) and run it to its end from this installed plane. With no flags it is canonical,
12508
+ * refused while any case is a draft. Each attempt is a child process: `evals attempt`.
12509
+ * - `attempt <campaign> <case> <n> --world <dir> --db <world db>`: one attempt's world (eval-world.ts). Only under its
12510
+ * runner's dispatch gate, and only on the world's own database.
12511
+ * - `cases`: each installed case, its version, its pin, and whether a person has validated it yet.
12512
+ * - `recover [--abandon <campaign>]`: settle campaigns whose runner stopped; with --abandon, a person ends a stuck one.
12513
+ */
12514
+ export async function evalsCommand(positional, flags, context) {
12515
+ const { write, json } = context;
12516
+ // Loaded when an eval command runs, so no other command (nor the console's bundle) pays for the eval runner.
12517
+ const { admitEvalCampaign, campaignSpending, EVALS_ROOT, installedBuildDigest, loadEvalSet, planCampaign, recoverEvalCampaigns, runEvalCampaign } = await import("./evals.js");
12518
+ const { inEvalWorld } = await import("./eval-gate.js");
12519
+ const { evalCliEntry, runEvalWorld } = await import("./eval-world.js");
12520
+ const action = positional[0];
12521
+ const command = `evals${action === undefined ? "" : ` ${action}`}`;
12522
+ const usage = "Use: toolroll evals run [--diagnostic] [--case <ids>] [--k <n>] --as <you> --token <password> | toolroll evals cases | toolroll evals recover [--abandon <campaign> --as <you> --token <password>]";
12523
+ if (action === "recover" && flags.has("abandon"))
12524
+ return evalsAbandonCommand(flags, context);
12525
+ if (action === "recover") {
12526
+ // A campaign whose runner stopped: its unfinished attempts recorded as interrupted, then it ends incomplete.
12527
+ const recovered = recoverEvalCampaigns(context.store, context.clock());
12528
+ return succeed(write, json, command, { campaigns: recovered }, () => recovered.length === 0 ? ["No campaign is running."] : recovered.map(one => `${one.id}: ${one.recovered ? "recovered" : "left running"} (${one.why})`));
12529
+ }
12530
+ if (action === "cases") {
12531
+ const set = loadEvalSet();
12532
+ if (!set.ok)
12533
+ return fail(write, json, command, "unreadable", set.problem, EXIT.failed);
12534
+ const cases = set.cases.map(one => ({ id: one.id, kind: one.kind, version: one.version, pin: one.pin, validatedBy: one.validatedBy, draft: one.validatedBy === null }));
12535
+ return succeed(write, json, command, { manifestDigest: set.manifestDigest, cases }, () => cases.map(one => `${one.id} (${one.kind}) ${one.version.slice(0, 12)} on ${one.pin.model}: ${one.draft ? "draft, awaiting a person's validation" : `validated by ${one.validatedBy}`}`));
12536
+ }
12537
+ if (action === "attempt") {
12538
+ // Only its campaign's runner starts an attempt: under the runner's dispatch gate, on the world's own database.
12539
+ if (!inEvalWorld())
12540
+ return fail(write, json, command, "refused", "An eval attempt runs only from its campaign's runner.", EXIT.refused);
12541
+ const [, campaign, caseId, n] = positional;
12542
+ const world = text(flags, "world");
12543
+ if (campaign === undefined || caseId === undefined || n === undefined || !/^[1-9][0-9]?$/.test(n) || world === undefined)
12544
+ return fail(write, json, command, "usage", "Use: toolroll evals attempt <campaign> <case> <n> --world <dir> --db <world>/toolroll/orders.db", EXIT.usage);
12545
+ let worldDir;
12546
+ try {
12547
+ worldDir = realpathSync(world);
12548
+ }
12549
+ catch {
12550
+ return fail(write, json, command, "refused", "The world's folder can't be read.", EXIT.refused);
12551
+ }
12552
+ if (resolve(context.databaseFile) !== join(worldDir, "toolroll", "orders.db"))
12553
+ return fail(write, json, command, "refused", "An eval attempt runs only on its world's own database.", EXIT.refused);
12554
+ const set = loadEvalSet();
12555
+ const one = set.ok ? set.cases.find(item => item.id === caseId) : undefined;
12556
+ if (one === undefined)
12557
+ return fail(write, json, command, "refused", set.ok ? `No case is named ${caseId}.` : set.problem, EXIT.refused);
12558
+ const deny = (text(flags, "deny") ?? "").split(",").filter(path => path !== "" && isAbsolute(path));
12559
+ const report = await runEvalWorld(one, { worldDir, databaseFile: context.databaseFile, deny, timeoutMs: one.timeoutMinutes * 60_000 });
12560
+ return succeed(write, json, command, { ...report }, () => [JSON.stringify(report)]);
12561
+ }
12562
+ if (action !== "run" || positional.length !== 1)
12563
+ return fail(write, json, command, "usage", usage, EXIT.usage);
12564
+ if (inEvalWorld())
12565
+ return fail(write, json, command, "refused", "An eval world doesn't start campaigns of its own.", EXIT.refused);
12566
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined)
12567
+ return fail(write, json, command, "person-only", "Only a person starts an eval campaign: it spends money.", EXIT.refused);
12568
+ const k = text(flags, "k");
12569
+ const cases = text(flags, "case");
12570
+ const set = loadEvalSet();
12571
+ if (!set.ok)
12572
+ return fail(write, json, command, "unreadable", set.problem, EXIT.failed);
12573
+ const planned = planCampaign(set, { ...(cases === undefined ? {} : { cases: cases.split(",").filter(Boolean) }), ...(k === undefined ? {} : { k: Number(k) }), diagnostic: flag(flags, "diagnostic") });
12574
+ if (!planned.ok)
12575
+ return fail(write, json, command, "refused", planned.problem, EXIT.refused);
12576
+ const acting = await askCredentials(flags, context);
12577
+ if (acting === null)
12578
+ return fail(write, json, command, "usage", "An eval campaign is started with your password: add --as <you> --token <password>.", EXIT.usage);
12579
+ const { plan } = planned;
12580
+ const buildDigest = () => installedBuildDigest(PACKAGE_VERSION);
12581
+ const sealed = { cases: plan.cases.map(one => ({ id: one.id, version: one.version, pin: one.pin, maxDispatches: one.maxDispatches })), k: plan.k, baseline: null, evals: EVALS_ROOT };
12582
+ // A campaign whose runner stopped no longer holds its place.
12583
+ recoverEvalCampaigns(context.store, context.clock());
12584
+ const admitted = admitEvalCampaign(context.store, { kind: "eval", canonical: plan.canonical, manifestDigest: set.manifestDigest, buildDigest: buildDigest(), version: PACKAGE_VERSION, k: plan.k,
12585
+ sealed, as: acting.name, token: acting.token, now: context.clock() });
12586
+ if (!admitted.ok)
12587
+ return fail(write, json, command, admitted.reason, admitted.message, EXIT.refused);
12588
+ const stateDir = dirname(context.databaseFile);
12589
+ const entry = evalCliEntry();
12590
+ const keep = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "SHELL", "TMPDIR"];
12591
+ const ended = await runEvalCampaign(context.store, admitted.id, plan.cases, {
12592
+ // Beside the plane's state folder, never inside it: a world's builds read-deny the plane's state.
12593
+ workRoot: join(dirname(stateDir), "toolroll-eval-worlds", admitted.id),
12594
+ buildDigest,
12595
+ keepWorlds: flag(flags, "keep"),
12596
+ now: context.clock,
12597
+ command: input => ({ file: entry.file, args: [...entry.args, "evals", "attempt", input.campaign, input.caseId, String(input.n), "--world", input.worldDir, "--deny", stateDir, "--db", input.databaseFile, "--json"],
12598
+ env: { ...Object.fromEntries(keep.flatMap(name => process.env[name] === undefined ? [] : [[name, process.env[name]]])), XDG_CONFIG_HOME: join(input.worldDir, "config") } }),
12599
+ });
12600
+ const rows = context.store.handle.prepare("SELECT case_id, n, replaces, outcome, error_why, api_microusd, subscription_microusd, usage_unknown FROM eval_attempt WHERE campaign = ? ORDER BY case_id, n, replaces").all(admitted.id);
12601
+ const nullable = (value) => value === null || value === undefined ? null : Number(value);
12602
+ const attempts = rows.map(row => ({ case: String(row["case_id"]), n: Number(row["n"]), replaces: Number(row["replaces"]), outcome: row["outcome"] === null ? null : String(row["outcome"]),
12603
+ why: row["error_why"] === null ? null : String(row["error_why"]), apiMicrousd: nullable(row["api_microusd"]), subscriptionMicrousd: nullable(row["subscription_microusd"]), usageUnknown: Number(row["usage_unknown"]) === 1 }));
12604
+ // What was actually spent, as recorded: API dollars and subscription usage apart, unknown never shown as measured.
12605
+ const spent = campaignSpending(context.store, admitted.id);
12606
+ const dollars = (micro, unknown) => micro === null ? `unknown (${unknown} attempt${unknown === 1 ? "" : "s"} with unknown spending)` : `$${(micro / 1_000_000).toFixed(2)}`;
12607
+ return succeed(write, json, command, { campaign: admitted.id, canonical: plan.canonical, state: ended.state, why: ended.why, attempts, spending: spent }, () => [
12608
+ `Campaign ${admitted.id} (${plan.canonical ? "canonical" : "diagnostic"}) is ${ended.state}${ended.why === null ? "" : `: ${ended.why}`}.`,
12609
+ ...attempts.map(one => `${one.case} #${one.n}${one.replaces === 0 ? "" : " (replacement)"}: ${one.outcome ?? "not finished"}${one.why === null ? "" : ` — ${one.why}`}`),
12610
+ `API dollars: ${dollars(spent.apiMicrousd, spent.unknownAttempts)}. Subscription usage, not billed: ${dollars(spent.subscriptionMicrousd, spent.unknownAttempts)}.`,
12611
+ ]);
12612
+ }
12613
+ /**
12614
+ * `toolroll evals recover --abandon <campaign> --as <you> --token <password>` (PR #263 review): a person ends a campaign
12615
+ * recovery can't settle (its runner or a world's processes can't be proven gone). Its unfinished attempts are recorded
12616
+ * as errors and its spending as unknown, and it ends incomplete; nothing is signalled and no world is removed. A lead
12617
+ * token, a machine or an eval world is refused; the password is checked inside the write (evals.ts abandonEvalCampaign).
12618
+ */
12619
+ async function evalsAbandonCommand(flags, context) {
12620
+ const { write, json } = context;
12621
+ const command = "evals recover";
12622
+ const { abandonEvalCampaign } = await import("./evals.js");
12623
+ const { inEvalWorld } = await import("./eval-gate.js");
12624
+ const id = text(flags, "abandon");
12625
+ if (id === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(id))
12626
+ return fail(write, json, command, "usage", "Use: toolroll evals recover --abandon <campaign> --as <you> --token <password>", EXIT.usage);
12627
+ if (inEvalWorld() || machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined)
12628
+ return fail(write, json, command, "person-only", "Only a person abandons an eval campaign.", EXIT.refused);
12629
+ const acting = await askCredentials(flags, context);
12630
+ if (acting === null)
12631
+ return fail(write, json, command, "usage", "A campaign is abandoned with your password: add --as <you> --token <password>.", EXIT.usage);
12632
+ const ended = abandonEvalCampaign(context.store, { id, as: acting.name, token: acting.token, now: context.clock() });
12633
+ if (!ended.ok)
12634
+ return fail(write, json, command, ended.reason, ended.message, EXIT.refused);
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.`]);
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;