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

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 (58) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +22 -5
  3. package/docs/blocks/capability-stewardship.md +1 -1
  4. package/docs/blocks/learning-harnesses.md +1 -1
  5. package/docs/capability-stewardship.md +2 -4
  6. package/docs/ingestion.md +3 -3
  7. package/docs/inquiry-harnesses.md +2 -3
  8. package/docs/install.md +3 -3
  9. package/docs/learning-harnesses.md +2 -3
  10. package/docs/local-services.md +42 -3
  11. package/docs/obsidian-contract.md +23 -0
  12. package/docs/obsidian-plugin.md +214 -3
  13. package/docs/obsidian.md +271 -11
  14. package/docs/personal-workspace.md +13 -4
  15. package/docs/release-engineering.md +2 -1
  16. package/docs/repo-boundary-guard.md +26 -3
  17. package/docs/semantic-operation.md +4 -3
  18. package/docs/upgrade.md +174 -2
  19. package/fixtures/templates/local-library.v1.json +2 -1
  20. package/package.json +2 -1
  21. package/plugins/obsidian/main.js +57 -8
  22. package/plugins/obsidian/manifest.json +1 -1
  23. package/src/boundary/content-rules.mjs +532 -67
  24. package/src/boundary/policy.mjs +135 -56
  25. package/src/cli/run.mjs +3 -2
  26. package/src/commands/obsidian.mjs +23 -7
  27. package/src/graph/graph.mjs +13 -5
  28. package/src/graph/knowledge-graph.mjs +19 -1
  29. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  30. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  31. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  32. package/src/knowledge-health/rule-identity.mjs +14 -9
  33. package/src/personal-workspace/index.mjs +1 -1
  34. package/src/personal-workspace/selection.mjs +17 -1
  35. package/src/project/path-match.mjs +2 -1
  36. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  37. package/src/projection/obsidian/personal-workspace.mjs +87 -4
  38. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  39. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  40. package/src/projection/obsidian/publication/publisher.mjs +72 -5
  41. package/src/projection/project.mjs +3 -2
  42. package/src/runtime/obsidian/engine.mjs +293 -55
  43. package/src/runtime/obsidian/lifecycle.mjs +17 -12
  44. package/src/runtime/obsidian/login-item.mjs +4 -1
  45. package/src/runtime/obsidian/observation.mjs +36 -4
  46. package/src/runtime/obsidian/opening.mjs +3 -2
  47. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  48. package/src/runtime/obsidian/pipeline.mjs +44 -3
  49. package/src/runtime/obsidian/private-lock.mjs +39 -4
  50. package/src/runtime/obsidian/service-main.mjs +58 -12
  51. package/src/runtime/obsidian/service.mjs +66 -6
  52. package/src/runtime/obsidian/view-permission.mjs +334 -0
  53. package/src/ui/control-system.mjs +201 -0
  54. package/src/ui/html-primitives.mjs +3 -0
  55. package/src/upgrade/closure-diagnostics.mjs +135 -0
  56. package/src/upgrade/closure.mjs +649 -0
  57. package/src/upgrade/upgrade.mjs +19 -7
  58. package/templates/external-project-workspace/pack-lifecycle.json +2 -1
@@ -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,22 @@ 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
+ export function resolveServiceWorkspace({ project: whole, dataRoot, env = process.env, platform = process.platform, create = false }) {
80
+ const project = serviceWorkspaceInputs(whole)
65
81
  const pointer = readLocalPointer(project)
66
82
  if (pointer === null) return null
67
83
  const requested = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId)
@@ -87,7 +103,10 @@ async function writeRecordPatiently(input) {
87
103
 
88
104
  export async function runMaintenanceService(options = {}) {
89
105
  const {
90
- loadProject, dataRoot, adapterFactory, entryPath, runtimeId = `rt-${cryptoRandomBytes(16).toString('hex')}`, startup = false,
106
+ loadProject, dataRoot,
107
+ // What the workspace is resolved from at once, where the loader may answer a promise (locatePersonalHome): the service
108
+ // starts from it, and the engine awaits the loader at its ticks. Without it, the loader's own first answer is used.
109
+ locateProject = null, adapterFactory, entryPath, runtimeId = `rt-${cryptoRandomBytes(16).toString('hex')}`, startup = false,
91
110
  intervalMs = DEFAULT_TICK_INTERVAL_MS, maxBackoffMs = Math.max(DEFAULT_MAX_BACKOFF_MS, intervalMs), shutdownGraceMs = DEFAULT_SHUTDOWN_GRACE_MS,
92
111
  clock = () => new Date(), log = () => {}, pid = process.pid, randomBytes = cryptoRandomBytes, env = process.env, platform = process.platform,
93
112
  engineOptions = {}, createEngine = createMaintenanceEngine,
@@ -103,7 +122,18 @@ export async function runMaintenanceService(options = {}) {
103
122
  if (typeof adapterFactory !== 'function') throw new TypeError('the service needs an adapterFactory')
104
123
  if (typeof entryPath !== 'string') throw new TypeError('the service needs the path of its entry module')
105
124
 
106
- const workspace = resolveServiceWorkspace({ project: loadProject(), dataRoot, env, platform })
125
+ // The workspace comes from the project the loader answers now, or the locator. A loader without a locator, or a locator,
126
+ // that answers a promise is refused, typed, before anything is read from the promise (it has no pointer, no overlay and
127
+ // no repositories); its answer is let go unread. A located project names its folder, or is refused.
128
+ const project = locateProject === null ? loadProject() : locateProject()
129
+ if (typeof project?.then === 'function') {
130
+ // Let go without calling a `then` of its own, which a promise whose `constructor` reads as this realm's `Promise`
131
+ // may carry and which may throw; any other thenable is adopted, its `then` called once.
132
+ Promise.prototype.then.call(Promise.resolve(project), undefined, () => {})
133
+ 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')
134
+ }
135
+ if (locateProject !== null && typeof project?.configDir !== 'string') refuse('service-locator-invalid', 'the locator of this service answered no folder to resolve its workspace from')
136
+ const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
107
137
  if (!workspace?.workspaceRoot) refuse('service-workspace-not-prepared', 'this workspace has no private state yet; `start` prepares it')
108
138
  const { workspaceId, workspaceRoot } = workspace
109
139
  const settings = readServiceSettings({ workspaceRoot, workspaceId })
@@ -131,6 +161,10 @@ export async function runMaintenanceService(options = {}) {
131
161
  let stopping = false
132
162
  let consecutiveFailures = 0
133
163
  let lastTick = null
164
+ // What the last tick that did not fail named each refused view for (refusalCauses). A view the next tick names for the
165
+ // same reason is not written to the log again: a refusal that lasts (a folder that stays unwritable is tried again at
166
+ // every tick) is written once, and again when its reason changes or after a tick that failed or did not name it.
167
+ let refusedLastTick = new Map()
134
168
  let settleDone
135
169
  const done = new Promise((resolve) => { settleDone = resolve })
136
170
 
@@ -160,6 +194,14 @@ export async function runMaintenanceService(options = {}) {
160
194
  }
161
195
  }
162
196
  const prepareWithPlugin = (input) => (engineOptions.seams?.prepareView ?? productionPrepareView)({ ...input, plugin: pluginFor(input.scope.scopeId) })
197
+ // The engine's rule (engine.mjs): a prepareView that is not the one that ships, handed in with the cooperative
198
+ // preparation left as it ships or absent, prepares the view, synchronously; the engine then gets no cooperative seam.
199
+ // Nor does it when the cooperative preparation was handed in as null: that says there is none, as it does to the engine.
200
+ const handed = engineOptions.seams ?? {}
201
+ const replacedPrepare = typeof handed.prepareView === 'function' && handed.prepareView !== productionPrepareView
202
+ && (handed.prepareViewCooperatively === undefined || handed.prepareViewCooperatively === prepareViewCooperatively)
203
+ const cooperative = replacedPrepare || handed.prepareViewCooperatively === null ? null : handed.prepareViewCooperatively ?? prepareViewCooperatively
204
+ const prepareCooperativelyWithPlugin = cooperative === null ? null : (input) => cooperative({ ...input, plugin: pluginFor(input.scope.scopeId) })
163
205
  // 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
206
  const publishAndConfirm = async (input) => {
165
207
  const result = await (engineOptions.seams?.publishView ?? productionPublishView)(input)
@@ -174,8 +216,8 @@ export async function runMaintenanceService(options = {}) {
174
216
  // The engine also asks the factory itself (`forget`, on a tick somebody asked for): the wrapper keeps its methods.
175
217
  const pluginAwareAdapterFactory = Object.assign((input) => adapterFactory({ ...input, pluginReport: typeof input?.scope?.scopeId === 'string' ? pluginReportOf(input.scope.scopeId) : null }), adapterFactory)
176
218
  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 },
219
+ watcherFactory: createFsWatcherFactory(), ...engineOptions, seams: { ...(engineOptions.seams ?? {}), prepareView: prepareWithPlugin, prepareViewCooperatively: prepareCooperativelyWithPlugin, publishView: publishAndConfirm },
220
+ loadProject, locateProject, dataRoot, adapterFactory: pluginAwareAdapterFactory, clock, env, platform, lockOwner: { host, port, runtimeId },
179
221
  })
180
222
 
181
223
  const recordIsOurs = () => {
@@ -187,11 +229,20 @@ export async function runMaintenanceService(options = {}) {
187
229
  consecutiveFailures = outcome.consecutiveFailures
188
230
  if (outcome.ok) {
189
231
  lastTick = { at, state: outcome.report.state, reason: outcome.report.reason ?? outcome.report.refusal?.code ?? null }
232
+ // A view the tick refused for a file-system error: the view's freshness names the refusal, and this line the error.
233
+ const refused = new Map()
234
+ for (const item of outcome.report.refusalCauses ?? []) {
235
+ const reason = JSON.stringify([item.code, item.cause ?? null, item.cleanupCause ?? null])
236
+ refused.set(item.scopeId, reason)
237
+ 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 } : {}) })
238
+ }
239
+ refusedLastTick = refused
190
240
  const previous = readLastServiceError({ workspaceRoot, workspaceId })
191
241
  if (previous && previous.resolvedAt === null) writeLastServiceError({ workspaceRoot, workspaceId, document: { ...previous, consecutiveFailures: 0, resolvedAt: at } })
192
242
  return
193
243
  }
194
244
  lastTick = { at, state: 'failed', reason: errorCode(outcome.error) }
245
+ refusedLastTick = new Map()
195
246
  // The message may carry a path or a title: it goes to the private log and nowhere else.
196
247
  log({ at, event: 'tick-failed', code: errorCode(outcome.error), name: errorName(outcome.error), message: String(outcome.error?.message ?? outcome.error), consecutiveFailures })
197
248
  let previous = null
@@ -251,8 +302,16 @@ export async function runMaintenanceService(options = {}) {
251
302
 
252
303
  const healthStatus = () => (stopping ? 'stopped' : consecutiveFailures > 0 ? 'degraded' : 'healthy')
253
304
 
305
+ // What is stored about a view is not permission to tell it: the project is asked as it is now (view-permission.mjs).
306
+ // Its loads are counted for the status document; the log gets a line for those somebody has to see (createLoadReport).
307
+ const statusLoads = createLoadReport({ write: (entry) => log({ at: isoTime(clock), ...entry }) })
308
+ const viewPermitted = createViewPermission({
309
+ loadProject, resolveWorkspace: (loaded) => resolveServiceWorkspace({ project: loaded, dataRoot, env, platform }), workspaceInputsOf: serviceWorkspaceInputs,
310
+ workspaceId, workspaceRoot, onLoad: statusLoads.onLoad,
311
+ })
254
312
  // One view as the plugin is told about it: its freshness entry with held notes counted, and its open pending edits.
255
313
  function pluginStatusOf(scopeId) {
314
+ if (!viewPermitted(scopeId)) return { view: null, pendingEdits: null }
256
315
  let entry = null
257
316
  try { entry = stateStore.readFreshness()?.scopes.find((item) => item.scopeId === scopeId) ?? null } catch (error) { if (!isTyped(error)) throw error; return { view: null, pendingEdits: null } }
258
317
  let open = null
@@ -281,6 +340,7 @@ export async function runMaintenanceService(options = {}) {
281
340
  return {
282
341
  schema: SERVICE_STATUS_SCHEMA, service: { ...identity, status: healthStatus() }, loop: loop.state(), lastTick, lastError, freshness: freshnessSummary(),
283
342
  plugins: pluginPresence({ sessions: pluginSessions, scopeIds: [...pluginChannel.bearers().keys()], entryOf: (scopeId) => currentPluginChoice({ workspaceRoot, workspaceId, scopeId }).state }),
343
+ statusLoads: statusLoads.counts(),
284
344
  ...(typeof appStatus === 'function' ? { app: appStatus() } : {}),
285
345
  }
286
346
  },
@@ -0,0 +1,334 @@
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. Any other
171
+ // load that answered in time and kept the project writes nothing unless it ends a row of failures: that is every load
172
+ // of a healthy service. A line holds the event, milliseconds, the outcome, flags and counts; never a path, a view or a
173
+ // message.
174
+ export function createLoadReport({ write, slowMs = DEFAULT_SLOW_LOAD_MS, rowEndsAfterMs = DEFAULT_ROW_ENDS_AFTER_MS }) {
175
+ if (typeof write !== 'function') throw new TypeError('a load report needs somewhere to write')
176
+ const counts = { answered: 0, failed: 0, givenUp: 0, slow: 0, notKept: 0, longestMs: 0 }
177
+ const doubled = (row) => (row & (row - 1)) === 0
178
+ let failedInARow = 0
179
+ let lastFailedAt = 0
180
+ let slowInARow = 0
181
+ let lastSlowAt = 0
182
+ let notKeptInARow = 0
183
+ return {
184
+ // `kept` and `sameFiles` default to a load that kept its project, `vouched` to files that vouched, and `at` to now.
185
+ onLoad({ durationMs, outcome, promised, kept = true, sameFiles = true, vouched = true, at = performance.now() }) {
186
+ const ms = Math.round(durationMs)
187
+ counts.longestMs = Math.max(counts.longestMs, ms)
188
+ if (outcome !== 'answered') {
189
+ counts[outcome === 'given-up' ? 'givenUp' : 'failed'] += 1
190
+ failedInARow += 1
191
+ lastFailedAt = at
192
+ if (doubled(failedInARow)) write({ event: STATUS_LOAD_EVENT, durationMs: ms, outcome, promised, inARow: failedInARow })
193
+ return
194
+ }
195
+ counts.answered += 1
196
+ const afterFailures = failedInARow > 0 && at - lastFailedAt >= rowEndsAfterMs ? failedInARow : 0
197
+ if (afterFailures > 0) failedInARow = 0
198
+ const slow = !promised && ms >= slowMs
199
+ if (slow) {
200
+ counts.slow += 1
201
+ slowInARow += 1
202
+ lastSlowAt = at
203
+ } else if (slowInARow > 0 && at - lastSlowAt >= rowEndsAfterMs) slowInARow = 0
204
+ const notKept = sameFiles && !kept
205
+ if (notKept) {
206
+ counts.notKept += 1
207
+ notKeptInARow += 1
208
+ } else if (sameFiles) notKeptInARow = 0
209
+ const writeSlow = slow && doubled(slowInARow)
210
+ const writeNotKept = notKept && notKeptInARow >= 2 && doubled(notKeptInARow)
211
+ if (afterFailures > 0 || writeSlow || writeNotKept) {
212
+ write({
213
+ event: STATUS_LOAD_EVENT, durationMs: ms, outcome, promised,
214
+ ...(afterFailures > 0 ? { afterFailures } : {}),
215
+ ...(writeSlow ? { slow: true, inARow: slowInARow } : {}),
216
+ ...(writeNotKept ? { notKept: true, notKeptInARow, vouched } : {}),
217
+ })
218
+ }
219
+ },
220
+ // answered, failed, givenUp, slow and notKept loads so far, and the longest in milliseconds.
221
+ counts: () => ({ ...counts }),
222
+ }
223
+ }
224
+
225
+ // `resolveWorkspace(project)` answers { workspaceId, workspaceRoot } as the project names them now, or null, and
226
+ // `workspaceInputsOf(project)` what it reads of a project to do so, as JSON: the service hands in its own of both
227
+ // (resolveServiceWorkspace, serviceWorkspaceInputs), so what two loads are compared by is never a copy kept here.
228
+ // `now()` answers milliseconds that only go up; the times are this module's defaults unless a test gives its own.
229
+ // `realpath(file)` resolves a path as the system does; a test hands in one that fails.
230
+ export function createViewPermission({
231
+ loadProject, resolveWorkspace, workspaceInputsOf, workspaceId, workspaceRoot, now = () => performance.now(),
232
+ keepForMs = DEFAULT_KEEP_FOR_MS, loadDeadlineMs = DEFAULT_LOAD_DEADLINE_MS, retryAfterMs = DEFAULT_RETRY_AFTER_MS, retryAtMostMs = DEFAULT_RETRY_AT_MOST_MS,
233
+ onLoad = () => {}, realpath = fs.realpathSync.native,
234
+ }) {
235
+ if (typeof loadProject !== 'function') throw new TypeError('a view permission needs loadProject')
236
+ if (typeof resolveWorkspace !== 'function') throw new TypeError('a view permission needs resolveWorkspace')
237
+ if (typeof workspaceInputsOf !== 'function') throw new TypeError('a view permission needs workspaceInputsOf')
238
+ const digestOf = (files) => digestOver(files, realpath)
239
+ // What two loads have to agree on: the views, and what the workspace is resolved from. Nothing else of a project is
240
+ // compared, so a field that differs from load to load (a time, a binding under a symbol key) does not keep a project
241
+ // from being kept, and nothing a decision reads is left out.
242
+ const decidedBy = (project, views) => JSON.stringify([[...views].sort(), workspaceInputsOf(project)])
243
+ // The files that decided the project last loaded.
244
+ let files = null
245
+ // What the last load returned that was started over files that vouched: { digest, shape }.
246
+ let seen = null
247
+ // The project two such loads agreed on: { project, views, digest, since }, `since` the start of the second.
248
+ let kept = null
249
+ // A load that answered a promise and has not answered yet: { id, digest, startedAt }.
250
+ let underWay = null
251
+ // Loads that failed in a row while the files held the same: { digest, times, at }.
252
+ let failed = null
253
+ let started = 0
254
+
255
+ function fail(digest, at) {
256
+ kept = null
257
+ failed = { digest, times: failed !== null && failed.digest === digest ? failed.times + 1 : 1, at }
258
+ }
259
+ const tooSoon = (digest, at) => failed !== null && failed.digest === digest && at - failed.at < Math.min(retryAtMostMs, retryAfterMs * 2 ** (failed.times - 1))
260
+ // `before` and `vouched`: the files that decided the project when the load started, and whether they vouched then.
261
+ const sameList = (left, right) => left !== null && right !== null && left.length === right.length && left.every((file, index) => file === right[index])
262
+ const told = (startedAt, outcome, promised, { before, vouched }) => {
263
+ 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 */ }
264
+ }
265
+
266
+ // What a load returned; `digest` was read before it started. A project that names other files than the ones read
267
+ // is kept under a digest the next request cannot read again, so it is dropped there, before it decides anything.
268
+ function answered(project, digest, since) {
269
+ files = configFilesOf(project).map((file) => file.absolute)
270
+ const views = viewsOf(project)
271
+ const loaded = { project, views, digest, since }
272
+ const shape = digest === null ? null : decidedBy(project, views)
273
+ kept = digest !== null && seen !== null && seen.digest === digest && seen.shape === shape ? loaded : null
274
+ seen = digest === null ? null : { digest, shape }
275
+ return loaded
276
+ }
277
+
278
+ // Asks the loader. What it returned, when it answers at once; null when it answers a promise.
279
+ function load(digest, at) {
280
+ const as = { before: files, vouched: digest !== null }
281
+ let answer
282
+ try {
283
+ answer = loadProject()
284
+ if (typeof answer?.then !== 'function') {
285
+ const loaded = answered(answer, digest, at)
286
+ failed = null
287
+ told(at, 'answered', false, as)
288
+ return loaded
289
+ }
290
+ } catch (error) { fail(digest, at); told(at, 'failed', false, as); throw error }
291
+ const id = (started += 1)
292
+ underWay = { id, digest, startedAt: at, as }
293
+ // Only the load under way is listened to: one given up answers nobody. An answer that comes once the deadline has
294
+ // passed gives the load up itself, when no request was there to see the deadline pass first.
295
+ const mine = () => {
296
+ if (underWay?.id !== id) return false
297
+ underWay = null
298
+ if (now() - at < loadDeadlineMs) return true
299
+ fail(digest, now())
300
+ told(at, 'given-up', true, as)
301
+ return false
302
+ }
303
+ Promise.resolve(answer).then((project) => {
304
+ if (!mine()) return
305
+ answered(project, digest, at)
306
+ // While a file vouches for nothing, nothing can be kept from this answer, and no request is here to be decided by it.
307
+ if (digestOf(files) === null) { fail(null, now()); told(at, 'failed', true, as) } else { failed = null; told(at, 'answered', true, as) }
308
+ }, () => { if (mine()) { fail(digest, now()); told(at, 'failed', true, as) } }).catch(() => { fail(digest, now()); told(at, 'failed', true, as) })
309
+ return null
310
+ }
311
+
312
+ // The project as it is now, or null while that is not known.
313
+ function current() {
314
+ const at = now()
315
+ const digest = files === null ? null : digestOf(files)
316
+ if (kept !== null && (digest !== kept.digest || at - kept.since >= keepForMs)) kept = null
317
+ if (underWay !== null && at - underWay.startedAt >= loadDeadlineMs) { const given = underWay; underWay = null; fail(given.digest, at); told(given.startedAt, 'given-up', true, given.as) }
318
+ if (kept !== null && at - kept.since < keepForMs / 2) return kept
319
+ if (underWay === null && !tooSoon(digest, at)) {
320
+ const loaded = load(digest, at)
321
+ if (loaded !== null) return loaded
322
+ }
323
+ return kept
324
+ }
325
+
326
+ return function permits(scopeId) {
327
+ try {
328
+ const known = current()
329
+ if (known === null || !known.views.has(scopeId)) return false
330
+ const workspace = resolveWorkspace(known.project)
331
+ return workspace?.workspaceId === workspaceId && workspace.workspaceRoot === workspaceRoot
332
+ } catch { return false }
333
+ }
334
+ }