@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -1,8 +1,9 @@
1
+ import { prepareViewCooperatively } from '../../projection/obsidian/materialize/prepare-view.mjs'
1
2
  import { randomBytes as cryptoRandomBytes } from 'node:crypto'
2
3
  import fs from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import { AtelierDiagnosticError } from '../../project/config.mjs'
5
- import { ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
6
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
6
7
  import { prepareView as productionPrepareView } from '../../projection/obsidian/materialize/index.mjs'
7
8
  import { preparePluginFiles } from '../../projection/obsidian/plugin-bridge/bundle.mjs'
8
9
  import { publishView as productionPublishView } from '../../projection/obsidian/publication/publisher.mjs'
@@ -22,6 +23,7 @@ import {
22
23
  import { createServiceServer } from './service-server.mjs'
23
24
  import { OPEN_EDIT_STATES, createMaintenanceStateStore } from './state-store.mjs'
24
25
  import { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
26
+ import { createLoadReport, createViewPermission } from './view-permission.mjs'
25
27
  import { createFsWatcherFactory } from './watchers.mjs'
26
28
 
27
29
  // The maintenance service of one workspace, inside one process.
@@ -60,8 +62,31 @@ export const SERVICE_STATUS_SCHEMA = 'atelier-obsidian-service-status/v1'
60
62
  export const DEFAULT_SHUTDOWN_GRACE_MS = 30 * 1000
61
63
  export const RELEASE_CHANGED = 'release-changed'
62
64
 
65
+ // What of a project its workspace is resolved from, as a project that holds nothing else: the folder its pointer is
66
+ // under (readLocalPointer), the data root its local overlay prefers (resolveDataRoot), and the repositories private
67
+ // state may not overlap (protectedRoots). resolveServiceWorkspace reads these and nothing else of a project, so two
68
+ // projects with the same inputs resolve alike, and the plugin's status read compares exactly them (view-permission.mjs).
69
+ export function serviceWorkspaceInputs(project) {
70
+ const preference = project?.localOverlay?.overlay?.preferences?.[OBSIDIAN_EXT_KEY]
71
+ return {
72
+ ...(typeof project?.configDir === 'string' ? { configDir: project.configDir } : {}),
73
+ repos: (Array.isArray(project?.repos) ? project.repos : []).map((repo) => ({ path: repo?.path, external: Boolean(repo?.external) })),
74
+ localOverlay: { overlay: { preferences: preference === undefined ? {} : { [OBSIDIAN_EXT_KEY]: preference } } },
75
+ }
76
+ }
77
+
63
78
  // Where a workspace keeps its private state, without creating an identity.
64
- export function resolveServiceWorkspace({ project, dataRoot, env = process.env, platform = process.platform, create = false }) {
79
+ // The same location check resolveServiceWorkspace applies, for an identity that is not yet persisted.
80
+ export function serviceLocationCheck({ project: whole, dataRoot, env = process.env, platform = process.platform }) {
81
+ const project = serviceWorkspaceInputs(whole)
82
+ return (pointer) => assertOutsideRepositories({
83
+ managedRoot: workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId),
84
+ repositoryRoots: protectedRoots(project),
85
+ })
86
+ }
87
+
88
+ export function resolveServiceWorkspace({ project: whole, dataRoot, env = process.env, platform = process.platform, create = false }) {
89
+ const project = serviceWorkspaceInputs(whole)
65
90
  const pointer = readLocalPointer(project)
66
91
  if (pointer === null) return null
67
92
  const requested = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId)
@@ -71,6 +96,8 @@ export function resolveServiceWorkspace({ project, dataRoot, env = process.env,
71
96
  return { workspaceId: pointer.workspaceId, workspaceRoot: fs.realpathSync(requested) }
72
97
  }
73
98
 
99
+ // A refusal that the private-state root is no longer proven outside every declared root: nothing more is written there.
100
+ export const isLocationRefusalCode = (code) => /^managed-root-|^repository-inside-managed-root$/.test(String(code ?? ''))
74
101
  const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal
75
102
  const errorCode = (error) => (typeof error?.code === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(error.code) ? error.code : 'untyped-error')
76
103
  const errorName = (error) => (typeof error?.name === 'string' ? error.name.slice(0, 64) : 'Error')
@@ -87,7 +114,10 @@ async function writeRecordPatiently(input) {
87
114
 
88
115
  export async function runMaintenanceService(options = {}) {
89
116
  const {
90
- loadProject, dataRoot, adapterFactory, entryPath, runtimeId = `rt-${cryptoRandomBytes(16).toString('hex')}`, startup = false,
117
+ loadProject, dataRoot,
118
+ // What the workspace is resolved from at once, where the loader may answer a promise (locatePersonalHome): the service
119
+ // starts from it, and the engine awaits the loader at its ticks. Without it, the loader's own first answer is used.
120
+ locateProject = null, adapterFactory, entryPath, runtimeId = `rt-${cryptoRandomBytes(16).toString('hex')}`, startup = false,
91
121
  intervalMs = DEFAULT_TICK_INTERVAL_MS, maxBackoffMs = Math.max(DEFAULT_MAX_BACKOFF_MS, intervalMs), shutdownGraceMs = DEFAULT_SHUTDOWN_GRACE_MS,
92
122
  clock = () => new Date(), log = () => {}, pid = process.pid, randomBytes = cryptoRandomBytes, env = process.env, platform = process.platform,
93
123
  engineOptions = {}, createEngine = createMaintenanceEngine,
@@ -103,7 +133,18 @@ export async function runMaintenanceService(options = {}) {
103
133
  if (typeof adapterFactory !== 'function') throw new TypeError('the service needs an adapterFactory')
104
134
  if (typeof entryPath !== 'string') throw new TypeError('the service needs the path of its entry module')
105
135
 
106
- const workspace = resolveServiceWorkspace({ project: loadProject(), dataRoot, env, platform })
136
+ // The workspace comes from the project the loader answers now, or the locator. A loader without a locator, or a locator,
137
+ // that answers a promise is refused, typed, before anything is read from the promise (it has no pointer, no overlay and
138
+ // no repositories); its answer is let go unread. A located project names its folder, or is refused.
139
+ const project = locateProject === null ? loadProject() : locateProject()
140
+ if (typeof project?.then === 'function') {
141
+ // Let go without calling a `then` of its own, which a promise whose `constructor` reads as this realm's `Promise`
142
+ // may carry and which may throw; any other thenable is adopted, its `then` called once.
143
+ Promise.prototype.then.call(Promise.resolve(project), undefined, () => {})
144
+ refuse('service-loader-not-synchronous', 'the service resolves its workspace from a project its loader answers at once; a loader that answers a promise is not supported when the service starts')
145
+ }
146
+ if (locateProject !== null && typeof project?.configDir !== 'string') refuse('service-locator-invalid', 'the locator of this service answered no folder to resolve its workspace from')
147
+ const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
107
148
  if (!workspace?.workspaceRoot) refuse('service-workspace-not-prepared', 'this workspace has no private state yet; `start` prepares it')
108
149
  const { workspaceId, workspaceRoot } = workspace
109
150
  const settings = readServiceSettings({ workspaceRoot, workspaceId })
@@ -131,6 +172,10 @@ export async function runMaintenanceService(options = {}) {
131
172
  let stopping = false
132
173
  let consecutiveFailures = 0
133
174
  let lastTick = null
175
+ // What the last tick that did not fail named each refused view for (refusalCauses). A view the next tick names for the
176
+ // same reason is not written to the log again: a refusal that lasts (a folder that stays unwritable is tried again at
177
+ // every tick) is written once, and again when its reason changes or after a tick that failed or did not name it.
178
+ let refusedLastTick = new Map()
134
179
  let settleDone
135
180
  const done = new Promise((resolve) => { settleDone = resolve })
136
181
 
@@ -160,6 +205,14 @@ export async function runMaintenanceService(options = {}) {
160
205
  }
161
206
  }
162
207
  const prepareWithPlugin = (input) => (engineOptions.seams?.prepareView ?? productionPrepareView)({ ...input, plugin: pluginFor(input.scope.scopeId) })
208
+ // The engine's rule (engine.mjs): a prepareView that is not the one that ships, handed in with the cooperative
209
+ // preparation left as it ships or absent, prepares the view, synchronously; the engine then gets no cooperative seam.
210
+ // Nor does it when the cooperative preparation was handed in as null: that says there is none, as it does to the engine.
211
+ const handed = engineOptions.seams ?? {}
212
+ const replacedPrepare = typeof handed.prepareView === 'function' && handed.prepareView !== productionPrepareView
213
+ && (handed.prepareViewCooperatively === undefined || handed.prepareViewCooperatively === prepareViewCooperatively)
214
+ const cooperative = replacedPrepare || handed.prepareViewCooperatively === null ? null : handed.prepareViewCooperatively ?? prepareViewCooperatively
215
+ const prepareCooperativelyWithPlugin = cooperative === null ? null : (input) => cooperative({ ...input, plugin: pluginFor(input.scope.scopeId) })
163
216
  // An entry offered to a vault and now in place is confirmed: from then on, a list without it is the person's decision.
164
217
  const publishAndConfirm = async (input) => {
165
218
  const result = await (engineOptions.seams?.publishView ?? productionPublishView)(input)
@@ -174,8 +227,8 @@ export async function runMaintenanceService(options = {}) {
174
227
  // The engine also asks the factory itself (`forget`, on a tick somebody asked for): the wrapper keeps its methods.
175
228
  const pluginAwareAdapterFactory = Object.assign((input) => adapterFactory({ ...input, pluginReport: typeof input?.scope?.scopeId === 'string' ? pluginReportOf(input.scope.scopeId) : null }), adapterFactory)
176
229
  const engine = createEngine({
177
- watcherFactory: createFsWatcherFactory(), ...engineOptions, seams: { ...(engineOptions.seams ?? {}), prepareView: prepareWithPlugin, publishView: publishAndConfirm },
178
- loadProject, dataRoot, adapterFactory: pluginAwareAdapterFactory, clock, env, platform, lockOwner: { host, port, runtimeId },
230
+ watcherFactory: createFsWatcherFactory(), ...engineOptions, seams: { ...(engineOptions.seams ?? {}), prepareView: prepareWithPlugin, prepareViewCooperatively: prepareCooperativelyWithPlugin, publishView: publishAndConfirm },
231
+ loadProject, locateProject, dataRoot, adapterFactory: pluginAwareAdapterFactory, clock, env, platform, lockOwner: { host, port, runtimeId },
179
232
  })
180
233
 
181
234
  const recordIsOurs = () => {
@@ -187,13 +240,25 @@ export async function runMaintenanceService(options = {}) {
187
240
  consecutiveFailures = outcome.consecutiveFailures
188
241
  if (outcome.ok) {
189
242
  lastTick = { at, state: outcome.report.state, reason: outcome.report.reason ?? outcome.report.refusal?.code ?? null }
243
+ // A view the tick refused for a file-system error: the view's freshness names the refusal, and this line the error.
244
+ const refused = new Map()
245
+ for (const item of outcome.report.refusalCauses ?? []) {
246
+ const reason = JSON.stringify([item.code, item.cause ?? null, item.cleanupCause ?? null])
247
+ refused.set(item.scopeId, reason)
248
+ if (refusedLastTick.get(item.scopeId) !== reason) log({ at, event: 'view-refused', scopeId: item.scopeId, code: item.code, ...(item.cause ? { cause: item.cause } : {}), ...(item.cleanupCause ? { cleanupCause: item.cleanupCause } : {}) })
249
+ }
250
+ refusedLastTick = refused
251
+ // A tick the engine refused for the location of private state leaves the error record as it is.
252
+ if (isLocationRefusalCode(outcome.report.refusal?.code)) return
190
253
  const previous = readLastServiceError({ workspaceRoot, workspaceId })
191
254
  if (previous && previous.resolvedAt === null) writeLastServiceError({ workspaceRoot, workspaceId, document: { ...previous, consecutiveFailures: 0, resolvedAt: at } })
192
255
  return
193
256
  }
194
257
  lastTick = { at, state: 'failed', reason: errorCode(outcome.error) }
258
+ refusedLastTick = new Map()
195
259
  // The message may carry a path or a title: it goes to the private log and nowhere else.
196
260
  log({ at, event: 'tick-failed', code: errorCode(outcome.error), name: errorName(outcome.error), message: String(outcome.error?.message ?? outcome.error), consecutiveFailures })
261
+ if (isLocationRefusalCode(outcome.error?.code)) return
197
262
  let previous = null
198
263
  try { previous = readLastServiceError({ workspaceRoot, workspaceId }) } catch { previous = null }
199
264
  writeLastServiceError({ workspaceRoot, workspaceId, document: { schema: SERVICE_ERROR_SCHEMA, workspaceId, runtimeId, code: errorCode(outcome.error), name: errorName(outcome.error), at, consecutiveFailures, totalFailures: (previous?.totalFailures ?? 0) + 1, resolvedAt: null } })
@@ -251,8 +316,16 @@ export async function runMaintenanceService(options = {}) {
251
316
 
252
317
  const healthStatus = () => (stopping ? 'stopped' : consecutiveFailures > 0 ? 'degraded' : 'healthy')
253
318
 
319
+ // What is stored about a view is not permission to tell it: the project is asked as it is now (view-permission.mjs).
320
+ // Its loads are counted for the status document; the log gets a line for those somebody has to see (createLoadReport).
321
+ const statusLoads = createLoadReport({ write: (entry) => log({ at: isoTime(clock), ...entry }) })
322
+ const viewPermitted = createViewPermission({
323
+ loadProject, resolveWorkspace: (loaded) => resolveServiceWorkspace({ project: loaded, dataRoot, env, platform }), workspaceInputsOf: serviceWorkspaceInputs,
324
+ workspaceId, workspaceRoot, onLoad: statusLoads.onLoad,
325
+ })
254
326
  // One view as the plugin is told about it: its freshness entry with held notes counted, and its open pending edits.
255
327
  function pluginStatusOf(scopeId) {
328
+ if (!viewPermitted(scopeId)) return { view: null, pendingEdits: null }
256
329
  let entry = null
257
330
  try { entry = stateStore.readFreshness()?.scopes.find((item) => item.scopeId === scopeId) ?? null } catch (error) { if (!isTyped(error)) throw error; return { view: null, pendingEdits: null } }
258
331
  let open = null
@@ -281,6 +354,7 @@ export async function runMaintenanceService(options = {}) {
281
354
  return {
282
355
  schema: SERVICE_STATUS_SCHEMA, service: { ...identity, status: healthStatus() }, loop: loop.state(), lastTick, lastError, freshness: freshnessSummary(),
283
356
  plugins: pluginPresence({ sessions: pluginSessions, scopeIds: [...pluginChannel.bearers().keys()], entryOf: (scopeId) => currentPluginChoice({ workspaceRoot, workspaceId, scopeId }).state }),
357
+ statusLoads: statusLoads.counts(),
284
358
  ...(typeof appStatus === 'function' ? { app: appStatus() } : {}),
285
359
  }
286
360
  },
@@ -6,7 +6,7 @@ import { realPathOfLocation } from './vault-location.mjs'
6
6
 
7
7
  // Whether the Obsidian this account runs lists a vault Atelier is filling for
8
8
  // the first time, as the publisher's `direct-unheld` path needs to know
9
- // (publisher.mjs; docs/obsidian-contract.md, "First publication into a vault
9
+ // (publisher.mjs; docs/reference/obsidian-contract.md, "First publication into a vault
10
10
  // no Obsidian lists"). Read from the app's settings file, never from the app:
11
11
  //
12
12
  // { unlisted: true } the file was read, and none of its entries is the vault root, a folder above
@@ -0,0 +1,335 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import { readObsidianEnablement } from './enablement.mjs'
4
+ import { DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, configFilesOf } from './engine.mjs'
5
+ import { readFileFacts } from './observation.mjs'
6
+
7
+ // Whether the project allows a view of this workspace right now.
8
+ //
9
+ // What the service stored about a view (its freshness, its pending edits) says
10
+ // what was true when it was written. A view can be turned off, or taken out of
11
+ // the project, or the project can point at another workspace, well before the
12
+ // next tick writes that down; and what a tick then writes still carries the
13
+ // view's generations and counts. So before a stored report is told to whoever
14
+ // holds the view open, `permits(scopeId)` asks the project as it is at that
15
+ // moment:
16
+ //
17
+ // the view the Obsidian settings are enabled and declare it
18
+ // the workspace the project's pointer names this workspace, and its
19
+ // private state is where this service keeps it
20
+ //
21
+ // Whatever cannot be established is "no": a project that does not load,
22
+ // settings that refuse, a pointer that cannot be read, a load that has not
23
+ // answered yet. Nothing is written. This is not about who may see a note; the
24
+ // audiences of machine-settings.mjs decide that when a view is prepared.
25
+ //
26
+ // Cost. Loading a project runs Git in child processes, and a plugin asks every
27
+ // two seconds for every vault it holds open. A project is therefore kept, and
28
+ // loaded again only when a file that decides what it is changed: the files the
29
+ // engine observes as configuration (configFilesOf), compared at every request
30
+ // by a digest of what they hold, never by their times. The pointer and the
31
+ // place of the workspace's private state are read at every request.
32
+ //
33
+ // what a file holds where it really is and the bytes there: its path
34
+ // resolved as the system resolves it when the loader
35
+ // opens it (realpath(3), through a link anywhere on the
36
+ // way), so a link pointed elsewhere, its own or a
37
+ // folder's above it, is a change. Nothing there is a
38
+ // state like any other. A folder in a file's place, a
39
+ // link that leads nowhere or to anything but a regular
40
+ // file, and a file that does not read vouch for nothing.
41
+ // Where the system resolves no path for a regular file
42
+ // that is there and is not a link (some volumes on
43
+ // Windows; a C library that needs /proc and has none),
44
+ // the file holds its own path as given and its bytes: a
45
+ // link above it pointed elsewhere is not seen there
46
+ // a project is kept when two loads in a row, each started after the
47
+ // files were read and found to hold the same, returned
48
+ // the same project as far as this permission reads one:
49
+ // the views it enables, and what the service's
50
+ // workspace resolution reads of it, handed in as
51
+ // `workspaceInputsOf` (where its pointer
52
+ // is, the data root its local overlay prefers, the
53
+ // roots it protects). One load is not enough: a file
54
+ // changed and changed back while it ran leaves the same
55
+ // bytes around a project read from other ones, and
56
+ // reading the files once more when it answers shows
57
+ // nothing. The second load reads the project again.
58
+ // The first load of all only tells which files decide
59
+ // the project, so three loads keep the first project
60
+ // and two keep one after a change
61
+ // and used only while the files hold what they held before
62
+ // those loads, read again at every request
63
+ // and not for long a kept project is loaded again once it is half as old
64
+ // as the engine's full reconciliations are apart, and
65
+ // is never used past that interval: whatever no file
66
+ // here names is seen by then
67
+ //
68
+ // While a file vouches for nothing no project is kept: a loader that answers
69
+ // at once is asked at every request, and its answer decides that request, as
70
+ // it does for the two requests that come before a project is kept.
71
+ //
72
+ // A loader may answer a promise (one that composes a personal workspace off
73
+ // the event loop). The answer to a request never waits for it: a plugin gives
74
+ // a request a second and a half, and takes an answer that does not come for a
75
+ // service that is gone. The load is started, one at a time, and a request is
76
+ // only ever decided from a kept project: until two loads agreed the view is
77
+ // not permitted, and while a kept project is loaded again it goes on deciding,
78
+ // within its time. A load that has not answered by its deadline is given up,
79
+ // whether a request or its own late answer is the first to see the deadline
80
+ // passed, and what it answers then is ignored.
81
+ //
82
+ // A load that fails (it throws, it is refused, it is given up, or it answers a
83
+ // promise while a file vouches for nothing) ends whatever was kept, and is not
84
+ // asked for again at once: after two seconds, then four, up to thirty, for as
85
+ // long as the files hold the same. A change to them is tried at once, once a
86
+ // load has named them: until a load has answered, a corrected configuration
87
+ // waits for the next try. And while one of them vouches for nothing there is
88
+ // no digest to change: after a load that failed then (one that answers a
89
+ // promise, or one that answers at once and threw), a change to another file
90
+ // waits for the next try as well.
91
+ //
92
+ // `onLoad({ durationMs, outcome, promised, kept, sameFiles, vouched, at })` is
93
+ // told how long each load took and how it ended (`answered`, `failed` or
94
+ // `given-up`), whether a project is kept after it, whether it named the files
95
+ // that decide the project as they were when it started (not the first load,
96
+ // which started over none, nor one that named other files), whether those
97
+ // files vouched then, and when it ended (`now()`): times, codes and flags
98
+ // only. createLoadReport, below, counts them and decides which are worth a
99
+ // log line.
100
+
101
+ // A kept project is never used for longer than the engine goes without a full reconciliation.
102
+ export const DEFAULT_KEEP_FOR_MS = DEFAULT_FULL_RECONCILIATION_INTERVAL_MS
103
+ // Longer than the two minutes a composition in a worker is given, so one that runs out of its own time answers first.
104
+ export const DEFAULT_LOAD_DEADLINE_MS = 150 * 1000
105
+ export const DEFAULT_RETRY_AFTER_MS = 2 * 1000
106
+ export const DEFAULT_RETRY_AT_MOST_MS = 30 * 1000
107
+
108
+ // What one file holds, as [path, its real path, digest of the bytes there], or [path, null, null] when it is absent;
109
+ // undefined when it vouches for nothing. The real path is the system's own (realpath(3)): JavaScript's realpathSync
110
+ // takes a `..` after a link away before following the link, and names a file the loader never opens.
111
+ function heldBy(file, realpath) {
112
+ let real
113
+ try { real = realpath(file) } catch {
114
+ // Unresolved. Nothing there is absent. A regular file that is there, and is not a link, is read where it is given
115
+ // (readFileFacts follows no link at its end). A link, resolved or not, never is: it leads nowhere, or nobody can say where.
116
+ let stat
117
+ try { stat = fs.lstatSync(file, { throwIfNoEntry: false }) } catch (inner) { return inner.code === 'ENOTDIR' ? [file, null, null] : undefined }
118
+ if (stat === undefined) return [file, null, null]
119
+ if (!stat.isFile()) return undefined
120
+ real = file
121
+ }
122
+ let facts
123
+ try { facts = readFileFacts(real) } catch { return undefined }
124
+ return facts === null ? undefined : [file, real, facts.digest]
125
+ }
126
+
127
+ // One digest over what every file holds, in order. Null when one of them vouches for nothing.
128
+ function digestOver(files, realpath) {
129
+ const hash = createHash('sha256')
130
+ for (const file of files) {
131
+ const held = heldBy(file, realpath)
132
+ if (held === undefined) return null
133
+ hash.update(`${JSON.stringify(held)}\n`)
134
+ }
135
+ return hash.digest('hex')
136
+ }
137
+
138
+ // The views the project enables. Settings that refuse enable none.
139
+ function viewsOf(project) {
140
+ try {
141
+ const enablement = readObsidianEnablement(project)
142
+ return new Set(enablement.state === 'enabled' ? enablement.scopes.map((scope) => scope.scopeId) : [])
143
+ } catch { return new Set() }
144
+ }
145
+
146
+ export const STATUS_LOAD_EVENT = 'status-project-loaded'
147
+ // A second: most of the second and a half a plugin gives a request, spent on the listener's own event loop.
148
+ export const DEFAULT_SLOW_LOAD_MS = 1000
149
+ // How long a row of failed or slow loads goes on through loads that answered in time between them.
150
+ export const DEFAULT_ROW_ENDS_AFTER_MS = 60 * 1000
151
+
152
+ // Counts every load a permission is told about (onLoad), and writes a line for the ones somebody has to see:
153
+ //
154
+ // failed, given up the first in a row, then when the row is 2, 4, 8, ... long, so a loader that keeps failing
155
+ // writes a line less and less often, never one per try
156
+ // answered the one that ends a row of failures; one that answered at once and held the event loop for
157
+ // `slowMs` or more, on the same doubling row while slow loads follow each other; and one after
158
+ // which nothing is kept although it named the files it started over, from the second in a row
159
+ // on the same doubling row: a load at every request
160
+ //
161
+ // A row of failures ends only at a load that answers `rowEndsAfterMs` or more after its last failure, and a row of
162
+ // slow loads at a load that answers in time that long after its last slow one. While loads come closer together than
163
+ // that (a load at every request, or the retries after a failure), a loader that fails, or is slow, every other load or
164
+ // every third keeps one row, written less and less often, not a line per load. A kept project is loaded again only
165
+ // every half of its keep time, so there a row of failures ends at the next reload that answers: a loader that answers
166
+ // at least one reload between two failures writes each failure and the load that ends its row, as before, and one that
167
+ // fails at every reload and answers only at its retry keeps one row. A row of loads after which nothing is kept ends
168
+ // at the next that keeps a project. A load that named other files than it started over (the first, or one after a
169
+ // change that names other files) neither counts nor ends that row, so a start and a change, each with one load that
170
+ // has nothing to agree with yet, write none of these, unless the change lands while a load reads the files, names the
171
+ // same files and changes the views or what the workspace is resolved from (docs/reference/obsidian-plugin.md). Any other
172
+ // load that answered in time and kept the project writes nothing unless it ends a row of failures: that is every load
173
+ // of a healthy service. A line holds the event, milliseconds, the outcome, flags and counts; never a path, a view or a
174
+ // message.
175
+ export function createLoadReport({ write, slowMs = DEFAULT_SLOW_LOAD_MS, rowEndsAfterMs = DEFAULT_ROW_ENDS_AFTER_MS }) {
176
+ if (typeof write !== 'function') throw new TypeError('a load report needs somewhere to write')
177
+ const counts = { answered: 0, failed: 0, givenUp: 0, slow: 0, notKept: 0, longestMs: 0 }
178
+ const doubled = (row) => (row & (row - 1)) === 0
179
+ let failedInARow = 0
180
+ let lastFailedAt = 0
181
+ let slowInARow = 0
182
+ let lastSlowAt = 0
183
+ let notKeptInARow = 0
184
+ return {
185
+ // `kept` and `sameFiles` default to a load that kept its project, `vouched` to files that vouched, and `at` to now.
186
+ onLoad({ durationMs, outcome, promised, kept = true, sameFiles = true, vouched = true, at = performance.now() }) {
187
+ const ms = Math.round(durationMs)
188
+ counts.longestMs = Math.max(counts.longestMs, ms)
189
+ if (outcome !== 'answered') {
190
+ counts[outcome === 'given-up' ? 'givenUp' : 'failed'] += 1
191
+ failedInARow += 1
192
+ lastFailedAt = at
193
+ if (doubled(failedInARow)) write({ event: STATUS_LOAD_EVENT, durationMs: ms, outcome, promised, inARow: failedInARow })
194
+ return
195
+ }
196
+ counts.answered += 1
197
+ const afterFailures = failedInARow > 0 && at - lastFailedAt >= rowEndsAfterMs ? failedInARow : 0
198
+ if (afterFailures > 0) failedInARow = 0
199
+ const slow = !promised && ms >= slowMs
200
+ if (slow) {
201
+ counts.slow += 1
202
+ slowInARow += 1
203
+ lastSlowAt = at
204
+ } else if (slowInARow > 0 && at - lastSlowAt >= rowEndsAfterMs) slowInARow = 0
205
+ const notKept = sameFiles && !kept
206
+ if (notKept) {
207
+ counts.notKept += 1
208
+ notKeptInARow += 1
209
+ } else if (sameFiles) notKeptInARow = 0
210
+ const writeSlow = slow && doubled(slowInARow)
211
+ const writeNotKept = notKept && notKeptInARow >= 2 && doubled(notKeptInARow)
212
+ if (afterFailures > 0 || writeSlow || writeNotKept) {
213
+ write({
214
+ event: STATUS_LOAD_EVENT, durationMs: ms, outcome, promised,
215
+ ...(afterFailures > 0 ? { afterFailures } : {}),
216
+ ...(writeSlow ? { slow: true, inARow: slowInARow } : {}),
217
+ ...(writeNotKept ? { notKept: true, notKeptInARow, vouched } : {}),
218
+ })
219
+ }
220
+ },
221
+ // answered, failed, givenUp, slow and notKept loads so far, and the longest in milliseconds.
222
+ counts: () => ({ ...counts }),
223
+ }
224
+ }
225
+
226
+ // `resolveWorkspace(project)` answers { workspaceId, workspaceRoot } as the project names them now, or null, and
227
+ // `workspaceInputsOf(project)` what it reads of a project to do so, as JSON: the service hands in its own of both
228
+ // (resolveServiceWorkspace, serviceWorkspaceInputs), so what two loads are compared by is never a copy kept here.
229
+ // `now()` answers milliseconds that only go up; the times are this module's defaults unless a test gives its own.
230
+ // `realpath(file)` resolves a path as the system does; a test hands in one that fails.
231
+ export function createViewPermission({
232
+ loadProject, resolveWorkspace, workspaceInputsOf, workspaceId, workspaceRoot, now = () => performance.now(),
233
+ keepForMs = DEFAULT_KEEP_FOR_MS, loadDeadlineMs = DEFAULT_LOAD_DEADLINE_MS, retryAfterMs = DEFAULT_RETRY_AFTER_MS, retryAtMostMs = DEFAULT_RETRY_AT_MOST_MS,
234
+ onLoad = () => {}, realpath = fs.realpathSync.native,
235
+ }) {
236
+ if (typeof loadProject !== 'function') throw new TypeError('a view permission needs loadProject')
237
+ if (typeof resolveWorkspace !== 'function') throw new TypeError('a view permission needs resolveWorkspace')
238
+ if (typeof workspaceInputsOf !== 'function') throw new TypeError('a view permission needs workspaceInputsOf')
239
+ const digestOf = (files) => digestOver(files, realpath)
240
+ // What two loads have to agree on: the views, and what the workspace is resolved from. Nothing else of a project is
241
+ // compared, so a field that differs from load to load (a time, a binding under a symbol key) does not keep a project
242
+ // from being kept, and nothing a decision reads is left out.
243
+ const decidedBy = (project, views) => JSON.stringify([[...views].sort(), workspaceInputsOf(project)])
244
+ // The files that decided the project last loaded.
245
+ let files = null
246
+ // What the last load returned that was started over files that vouched: { digest, shape }.
247
+ let seen = null
248
+ // The project two such loads agreed on: { project, views, digest, since }, `since` the start of the second.
249
+ let kept = null
250
+ // A load that answered a promise and has not answered yet: { id, digest, startedAt }.
251
+ let underWay = null
252
+ // Loads that failed in a row while the files held the same: { digest, times, at }.
253
+ let failed = null
254
+ let started = 0
255
+
256
+ function fail(digest, at) {
257
+ kept = null
258
+ failed = { digest, times: failed !== null && failed.digest === digest ? failed.times + 1 : 1, at }
259
+ }
260
+ const tooSoon = (digest, at) => failed !== null && failed.digest === digest && at - failed.at < Math.min(retryAtMostMs, retryAfterMs * 2 ** (failed.times - 1))
261
+ // `before` and `vouched`: the files that decided the project when the load started, and whether they vouched then.
262
+ const sameList = (left, right) => left !== null && right !== null && left.length === right.length && left.every((file, index) => file === right[index])
263
+ const told = (startedAt, outcome, promised, { before, vouched }) => {
264
+ try { const at = now(); onLoad({ durationMs: Math.max(0, at - startedAt), outcome, promised, kept: kept !== null, sameFiles: sameList(before, files), vouched, at }) } catch { /* only told */ }
265
+ }
266
+
267
+ // What a load returned; `digest` was read before it started. A project that names other files than the ones read
268
+ // is kept under a digest the next request cannot read again, so it is dropped there, before it decides anything.
269
+ function answered(project, digest, since) {
270
+ files = configFilesOf(project).map((file) => file.absolute)
271
+ const views = viewsOf(project)
272
+ const loaded = { project, views, digest, since }
273
+ const shape = digest === null ? null : decidedBy(project, views)
274
+ kept = digest !== null && seen !== null && seen.digest === digest && seen.shape === shape ? loaded : null
275
+ seen = digest === null ? null : { digest, shape }
276
+ return loaded
277
+ }
278
+
279
+ // Asks the loader. What it returned, when it answers at once; null when it answers a promise.
280
+ function load(digest, at) {
281
+ const as = { before: files, vouched: digest !== null }
282
+ let answer
283
+ try {
284
+ answer = loadProject()
285
+ if (typeof answer?.then !== 'function') {
286
+ const loaded = answered(answer, digest, at)
287
+ failed = null
288
+ told(at, 'answered', false, as)
289
+ return loaded
290
+ }
291
+ } catch (error) { fail(digest, at); told(at, 'failed', false, as); throw error }
292
+ const id = (started += 1)
293
+ underWay = { id, digest, startedAt: at, as }
294
+ // Only the load under way is listened to: one given up answers nobody. An answer that comes once the deadline has
295
+ // passed gives the load up itself, when no request was there to see the deadline pass first.
296
+ const mine = () => {
297
+ if (underWay?.id !== id) return false
298
+ underWay = null
299
+ if (now() - at < loadDeadlineMs) return true
300
+ fail(digest, now())
301
+ told(at, 'given-up', true, as)
302
+ return false
303
+ }
304
+ Promise.resolve(answer).then((project) => {
305
+ if (!mine()) return
306
+ answered(project, digest, at)
307
+ // While a file vouches for nothing, nothing can be kept from this answer, and no request is here to be decided by it.
308
+ if (digestOf(files) === null) { fail(null, now()); told(at, 'failed', true, as) } else { failed = null; told(at, 'answered', true, as) }
309
+ }, () => { if (mine()) { fail(digest, now()); told(at, 'failed', true, as) } }).catch(() => { fail(digest, now()); told(at, 'failed', true, as) })
310
+ return null
311
+ }
312
+
313
+ // The project as it is now, or null while that is not known.
314
+ function current() {
315
+ const at = now()
316
+ const digest = files === null ? null : digestOf(files)
317
+ if (kept !== null && (digest !== kept.digest || at - kept.since >= keepForMs)) kept = null
318
+ if (underWay !== null && at - underWay.startedAt >= loadDeadlineMs) { const given = underWay; underWay = null; fail(given.digest, at); told(given.startedAt, 'given-up', true, given.as) }
319
+ if (kept !== null && at - kept.since < keepForMs / 2) return kept
320
+ if (underWay === null && !tooSoon(digest, at)) {
321
+ const loaded = load(digest, at)
322
+ if (loaded !== null) return loaded
323
+ }
324
+ return kept
325
+ }
326
+
327
+ return function permits(scopeId) {
328
+ try {
329
+ const known = current()
330
+ if (known === null || !known.views.has(scopeId)) return false
331
+ const workspace = resolveWorkspace(known.project)
332
+ return workspace?.workspaceId === workspaceId && workspace.workspaceRoot === workspaceRoot
333
+ } catch { return false }
334
+ }
335
+ }