@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.
- package/CHANGELOG.md +233 -0
- package/README.md +22 -5
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/capability-stewardship.md +2 -4
- package/docs/ingestion.md +3 -3
- package/docs/inquiry-harnesses.md +2 -3
- package/docs/install.md +3 -3
- package/docs/learning-harnesses.md +2 -3
- package/docs/local-services.md +42 -3
- package/docs/obsidian-contract.md +23 -0
- package/docs/obsidian-plugin.md +214 -3
- package/docs/obsidian.md +271 -11
- package/docs/personal-workspace.md +13 -4
- package/docs/release-engineering.md +2 -1
- package/docs/repo-boundary-guard.md +26 -3
- package/docs/semantic-operation.md +4 -3
- package/docs/upgrade.md +174 -2
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +2 -1
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/src/boundary/content-rules.mjs +532 -67
- package/src/boundary/policy.mjs +135 -56
- package/src/cli/run.mjs +3 -2
- package/src/commands/obsidian.mjs +23 -7
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +19 -1
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +17 -1
- package/src/project/path-match.mjs +2 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
- package/src/projection/obsidian/personal-workspace.mjs +87 -4
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +72 -5
- package/src/projection/project.mjs +3 -2
- package/src/runtime/obsidian/engine.mjs +293 -55
- package/src/runtime/obsidian/lifecycle.mjs +17 -12
- package/src/runtime/obsidian/login-item.mjs +4 -1
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +3 -2
- package/src/runtime/obsidian/personal-selection.mjs +125 -0
- package/src/runtime/obsidian/pipeline.mjs +44 -3
- package/src/runtime/obsidian/private-lock.mjs +39 -4
- package/src/runtime/obsidian/service-main.mjs +58 -12
- package/src/runtime/obsidian/service.mjs +66 -6
- package/src/runtime/obsidian/view-permission.mjs +334 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +19 -7
- package/templates/external-project-workspace/pack-lifecycle.json +2 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { performance } from 'node:perf_hooks'
|
|
3
4
|
import { AtelierDiagnosticError } from '../../project/config.mjs'
|
|
4
5
|
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../../projection/obsidian/contracts.mjs'
|
|
5
|
-
import {
|
|
6
|
+
import { createCooperativeBudget } from '../../projection/obsidian/materialize/prepare-view.mjs'
|
|
7
|
+
import { PERSONAL_VALIDATION_PENDING, notePersonalSelectionNow, personalSelectionBehind, personalWorkspaceBindingOf, personalWorkspaceInputs, validatePersonalWorkspace } from '../../projection/obsidian/personal-workspace.mjs'
|
|
6
8
|
import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
|
|
7
9
|
import { PublicationRefusal, allocatedFolderState, hasCommittedGeneration, readVaultAllocation } from '../../projection/obsidian/recovery/store.mjs'
|
|
8
10
|
import { canonicalJson, compareText, isoTime } from './documents.mjs'
|
|
@@ -13,7 +15,7 @@ import {
|
|
|
13
15
|
assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
|
|
14
16
|
readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
|
|
15
17
|
} from './machine-settings.mjs'
|
|
16
|
-
import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
|
|
18
|
+
import { configKey, hasIndexedHint, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, reconcileCooperatively, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
|
|
17
19
|
import { dispatchAutomaticApply, heldPaths, layoutHeldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
|
|
18
20
|
import { createProductionSeams, eligibilityFor } from './pipeline.mjs'
|
|
19
21
|
import { PLUGIN_FILES_WAIT_FOR_APP } from './plugin-presence.mjs'
|
|
@@ -103,6 +105,15 @@ export const ENGINE_PRIMITIVES = Object.freeze({
|
|
|
103
105
|
},
|
|
104
106
|
// The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
|
|
105
107
|
observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
|
|
108
|
+
// A change the guard sees in the middle of a tick is read, where it may be a confirmed selection, while it is there.
|
|
109
|
+
noteSelectionAtGuard: notePersonalSelectionNow,
|
|
110
|
+
// A loaded project pinned to a selection record below the highest record this process has read the history ending at
|
|
111
|
+
// is loaded again at the next tick, whatever the files say: another reader in the process may have read a
|
|
112
|
+
// confirmation that is gone again before this engine looked (personalSelectionBehind).
|
|
113
|
+
reloadsBehindSelection: personalSelectionBehind,
|
|
114
|
+
// After a tick that failed without a typed reason, the next one looks at every view again, as the first tick of a
|
|
115
|
+
// process does: see tick().
|
|
116
|
+
rechecksEveryViewAfterFailure: true,
|
|
106
117
|
// One engine per workspace at a time, across processes.
|
|
107
118
|
acquireEngineLock: acquirePrivateGenerationLock,
|
|
108
119
|
// The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
|
|
@@ -126,6 +137,33 @@ export const ENGINE_PRIMITIVES = Object.freeze({
|
|
|
126
137
|
heldNoteDigest({ file }) {
|
|
127
138
|
try { return readFileFacts(file)?.digest ?? null } catch { return null }
|
|
128
139
|
},
|
|
140
|
+
// Whether an error met by the check of a tick's settings, at a wait, is a failure to read them: a file-system error
|
|
141
|
+
// (no permission, an I/O error, no descriptor left), recognised as one is where a vault is placed. Anything else is a
|
|
142
|
+
// fault of the engine and stops the tick.
|
|
143
|
+
guardReadFailed: (error) => typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code),
|
|
144
|
+
// Whether an error met when the graph build, a view's preparation or the look again before its commit reads a
|
|
145
|
+
// source is a source, or a folder of sources, removed after this tick observed it: no such file (or a parent that is
|
|
146
|
+
// no longer a folder), at a path that is gone now and that this tick's walks over the sources (before the build, and
|
|
147
|
+
// the look again after it) recorded with a digest (a source of a repository the project observes), or a folder that
|
|
148
|
+
// held such a source. A source changed during the tick is refused as `mixed-read`; one removed is refused the same,
|
|
149
|
+
// and the next tick reads everything again. Anything else stops the tick, as before: a path still there, a path the
|
|
150
|
+
// walk listed and could not read (it was recorded without a digest), or one it never listed, so a path that can never
|
|
151
|
+
// be read still fails every tick.
|
|
152
|
+
sourceRemovedSinceObserved(error, { project, index }) {
|
|
153
|
+
if ((error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') || typeof error.path !== 'string' || !(index instanceof Map)) return false
|
|
154
|
+
const missing = path.resolve(error.path)
|
|
155
|
+
if (fs.existsSync(missing)) return false
|
|
156
|
+
for (const repo of project?.repos ?? []) {
|
|
157
|
+
if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
|
|
158
|
+
const relative = path.relative(path.resolve(repo.path), missing)
|
|
159
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) continue
|
|
160
|
+
const posix = relative.split(path.sep).join('/')
|
|
161
|
+
if (typeof index.get(sourceKey(repo.name, posix))?.digest === 'string') return true
|
|
162
|
+
const folder = sourceKey(repo.name, `${posix}/`)
|
|
163
|
+
for (const [key, entry] of index) if (key.startsWith(folder) && typeof entry?.digest === 'string') return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
},
|
|
129
167
|
})
|
|
130
168
|
|
|
131
169
|
const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
|
|
@@ -137,7 +175,7 @@ const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
|
|
|
137
175
|
// The configuration a tick observes. A project bound to a personal workspace adds the person's authored manifest and
|
|
138
176
|
// overlay and its generation's record: a change to any of them is a configuration change, so every view is prepared
|
|
139
177
|
// again, asks the composition, and is refused at once when the generation no longer holds.
|
|
140
|
-
const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
|
|
178
|
+
export const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
|
|
141
179
|
|
|
142
180
|
// An editor adapter that `build` makes, synchronously or not, the first time the publisher calls it; a refusal of
|
|
143
181
|
// `build` is thrown from that call, and `refusal()` answers it afterwards.
|
|
@@ -160,6 +198,9 @@ function uncoordinatedAdapter(refusal) {
|
|
|
160
198
|
}
|
|
161
199
|
// Views a tick may be asked to prepare again, at most, before it runs.
|
|
162
200
|
const MAX_PREPARATION_REQUESTS = 64
|
|
201
|
+
// At most this many views a tick refuses for a file-system error are named with that error's code (`refusalCauses`); the
|
|
202
|
+
// others of that tick are not named.
|
|
203
|
+
const MAX_REFUSAL_CAUSES = 64
|
|
163
204
|
|
|
164
205
|
function journalTime(journalId) {
|
|
165
206
|
const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
|
|
@@ -198,13 +239,32 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
198
239
|
quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
|
|
199
240
|
// A service names where it answers health, so a lock it leaves behind can be proven abandoned.
|
|
200
241
|
lockOwner = null, lockProbe = probeHealth,
|
|
242
|
+
// Answers at once, without loading, what the workspace is resolved from (locatePersonalHome), for a loader that can
|
|
243
|
+
// refuse before any project was loaded: the views published before are then told why nothing is published now.
|
|
244
|
+
locateProject = null,
|
|
201
245
|
} = options
|
|
202
246
|
if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
|
|
203
247
|
if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
|
|
204
248
|
// No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
|
|
205
249
|
if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
|
|
206
|
-
const
|
|
250
|
+
const production = createProductionSeams()
|
|
251
|
+
const seams = { ...production, ...(options.seams ?? {}) }
|
|
252
|
+
// A caller that replaced prepareView and left the cooperative preparation as it ships prepares views its own way: its
|
|
253
|
+
// seam is used, synchronously, whether it handed in that one member or the production seams with that member replaced.
|
|
254
|
+
if (seams.prepareView !== production.prepareView && seams.prepareViewCooperatively === production.prepareViewCooperatively) seams.prepareViewCooperatively = null
|
|
255
|
+
// The same for the graph: a caller that replaced buildGraph and left the cooperative build as it ships builds its own way.
|
|
256
|
+
if (seams.buildGraph !== production.buildGraph && seams.buildGraphCooperatively === production.buildGraphCooperatively) seams.buildGraphCooperatively = null
|
|
207
257
|
const rules = { ...ENGINE_PRIMITIVES, ...primitives }
|
|
258
|
+
// Optional local qualification telemetry. It has no authority to change a
|
|
259
|
+
// tick's result, and records elapsed boundaries rather than a budget claim.
|
|
260
|
+
const phaseTime = () => performance.timeOrigin + performance.now()
|
|
261
|
+
const reportPhase = (phase, startedMs) => {
|
|
262
|
+
try { seams.onPhase?.({ phase, startedMs, endedMs: phaseTime() }) } catch { /* telemetry never changes custody */ }
|
|
263
|
+
}
|
|
264
|
+
const measured = (phase, operation) => {
|
|
265
|
+
const startedMs = phaseTime()
|
|
266
|
+
try { return operation() } finally { reportPhase(phase, startedMs) }
|
|
267
|
+
}
|
|
208
268
|
|
|
209
269
|
const index = new Map()
|
|
210
270
|
const hintedKeys = new Set()
|
|
@@ -229,6 +289,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
229
289
|
let watching = { signature: null, handle: null }
|
|
230
290
|
let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
|
|
231
291
|
let heldLock = null
|
|
292
|
+
let releaseOwed = false
|
|
232
293
|
// Views somebody asked to have prepared and published once more, at the next tick.
|
|
233
294
|
const preparationRequests = new Set()
|
|
234
295
|
// The views the project declared at the last tick; null before the first.
|
|
@@ -250,6 +311,14 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
250
311
|
}
|
|
251
312
|
|
|
252
313
|
async function takeLock(workspaceRoot, workspaceId) {
|
|
314
|
+
// A lock an earlier tick could not give back is still this process's, and its ticket on disk unreleased: a tick
|
|
315
|
+
// that tried to take it anew would find its own ticket and answer `busy` until the process ends. It is given back
|
|
316
|
+
// first. A release that fails again fails this tick, and is owed to the next.
|
|
317
|
+
if (heldLock && releaseOwed) {
|
|
318
|
+
heldLock.release()
|
|
319
|
+
heldLock = null
|
|
320
|
+
releaseOwed = false
|
|
321
|
+
}
|
|
253
322
|
if (heldLock) return { acquired: true }
|
|
254
323
|
const lock = await rules.acquireEngineLock({
|
|
255
324
|
workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
|
|
@@ -258,10 +327,18 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
258
327
|
return lock
|
|
259
328
|
}
|
|
260
329
|
|
|
330
|
+
// Gives the lock back at the end of a tick. One that cannot be given back (its released ticket could not be written)
|
|
331
|
+
// stays held, and the failure is the tick's: the next tick gives it back before anything else. One given back owes
|
|
332
|
+
// nothing more, whether this tick took it or an earlier one could not give it back.
|
|
261
333
|
function dropLock() {
|
|
262
334
|
const lock = heldLock
|
|
335
|
+
if (lock === null) return
|
|
336
|
+
try { lock.release() } catch (error) {
|
|
337
|
+
releaseOwed = true
|
|
338
|
+
throw error
|
|
339
|
+
}
|
|
263
340
|
heldLock = null
|
|
264
|
-
|
|
341
|
+
releaseOwed = false
|
|
265
342
|
}
|
|
266
343
|
|
|
267
344
|
function onWatcherEvent(roots, { rootId, relative }) {
|
|
@@ -318,11 +395,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
318
395
|
// Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
|
|
319
396
|
async function persistOutcome({ enablement, state, reason, now, scopeIds = [] }) {
|
|
320
397
|
if (!known) {
|
|
398
|
+
let located = project
|
|
399
|
+
try { located ??= locateProject === null ? null : locateProject() } catch { located = null }
|
|
321
400
|
let pointer = null
|
|
322
|
-
try { pointer =
|
|
401
|
+
try { pointer = located ? readLocalPointer(located) : null } catch { pointer = null }
|
|
323
402
|
if (!pointer) return
|
|
324
403
|
let root
|
|
325
|
-
try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId) } catch { return }
|
|
404
|
+
try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project: located, env, platform }), pointer.workspaceId) } catch { return }
|
|
326
405
|
const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
|
|
327
406
|
if (!stateStore.exists()) return
|
|
328
407
|
known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
|
|
@@ -371,7 +450,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
371
450
|
if (personalWorkspaceBindingOf(project) === null) project = await loadProject()
|
|
372
451
|
} else {
|
|
373
452
|
const config = reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
|
|
374
|
-
if (config.changes.length > 0) {
|
|
453
|
+
if (config.changes.length > 0 || rules.reloadsBehindSelection(project)) {
|
|
375
454
|
project = await loadProject()
|
|
376
455
|
reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full: true, lstat })
|
|
377
456
|
}
|
|
@@ -401,6 +480,27 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
401
480
|
const eligibility = fixedEligibility ?? eligibilityFor({ machine, project })
|
|
402
481
|
const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
|
|
403
482
|
known = { stateStore, maintenanceMode: machine.maintenanceMode }
|
|
483
|
+
// A view refused for a file-system error (a vault folder that could not be allocated or placed, a configuration
|
|
484
|
+
// file that could not be read again, a source removed while it was read, candidates that could not be staged) is
|
|
485
|
+
// settled under the refusal's code alone, and such a tick ends without a failure. The error's code (`EACCES`, `EIO`,
|
|
486
|
+
// `ENOENT`), and that of a cleanup after it that failed too, are reported beside it for the service's log. Only
|
|
487
|
+
// codes shaped as a file-system error's are kept: never a message, which may name a path.
|
|
488
|
+
const refusalCauses = []
|
|
489
|
+
const errnoOf = (value) => (typeof value === 'string' && /^E[A-Z]+$/.test(value) ? value : null)
|
|
490
|
+
const noteRefusalCause = (scopeId, refusal) => {
|
|
491
|
+
const cause = errnoOf(refusal?.detail?.cause)
|
|
492
|
+
const cleanupCause = errnoOf(refusal?.detail?.cleanupCause)
|
|
493
|
+
if ((cause === null && cleanupCause === null) || refusalCauses.length >= MAX_REFUSAL_CAUSES) return
|
|
494
|
+
refusalCauses.push({ scopeId, code: refusal.code, ...(cause === null ? {} : { cause }), ...(cleanupCause === null ? {} : { cleanupCause }) })
|
|
495
|
+
}
|
|
496
|
+
// A view whose own vault cannot be placed: a typed refusal's code and detail, or `vault-location-unusable` for a
|
|
497
|
+
// file-system error there, whose code is its cause.
|
|
498
|
+
const refuseViewAlone = (scopeId, error) => {
|
|
499
|
+
const code = viewRefusalCode(error)
|
|
500
|
+
if (code === null) throw error
|
|
501
|
+
allocationRefusals.set(scopeId, code)
|
|
502
|
+
noteRefusalCause(scopeId, isTypedRefusal(error) ? error : { code, detail: { cause: error.code } })
|
|
503
|
+
}
|
|
404
504
|
// Each view's vault: allocated now where this workspace decided its vaults live, when it has none and was never
|
|
405
505
|
// published under the data root (vault-location.mjs). A view whose folder cannot be allocated is not prepared on
|
|
406
506
|
// this tick, and its freshness says why; the other views go on.
|
|
@@ -432,11 +532,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
432
532
|
vaults = known.vaults
|
|
433
533
|
}
|
|
434
534
|
ensureVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId, location: machine.decisions.location, projectName: projectDisplayName(project), repositoryRoots, vaults, allocatedPaths, now })
|
|
435
|
-
} catch (error) {
|
|
436
|
-
const code = viewRefusalCode(error)
|
|
437
|
-
if (code === null) throw error
|
|
438
|
-
allocationRefusals.set(scope.scopeId, code)
|
|
439
|
-
}
|
|
535
|
+
} catch (error) { refuseViewAlone(scope.scopeId, error) }
|
|
440
536
|
}
|
|
441
537
|
}
|
|
442
538
|
// Each view's store, on its own: one whose vault cannot be placed (a record naming a folder inside a repository,
|
|
@@ -444,11 +540,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
444
540
|
const scopes = []
|
|
445
541
|
for (const scope of enablement.scopes) {
|
|
446
542
|
if (allocationRefusals.has(scope.scopeId)) continue
|
|
447
|
-
try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) {
|
|
448
|
-
const code = viewRefusalCode(error)
|
|
449
|
-
if (code === null) throw error
|
|
450
|
-
allocationRefusals.set(scope.scopeId, code)
|
|
451
|
-
}
|
|
543
|
+
try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) { refuseViewAlone(scope.scopeId, error) }
|
|
452
544
|
}
|
|
453
545
|
|
|
454
546
|
watch([
|
|
@@ -460,20 +552,37 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
460
552
|
|
|
461
553
|
// 3. Vault notes: preserve, then queue. Compared with the bytes each note
|
|
462
554
|
// was generated with, not with the previous look.
|
|
463
|
-
|
|
464
|
-
|
|
555
|
+
//
|
|
556
|
+
// Every view's notes are looked at first, and the walk waits between notes, so a large vault does not hold the
|
|
557
|
+
// listener for its whole length (at a full reconciliation every note is hashed, and after a publication the
|
|
558
|
+
// watcher has named each note it wrote). Nothing is checked at those waits and nothing is written: within
|
|
559
|
+
// Atelier only this engine commits a manifest or writes the pending edits, under the lock this tick holds, so the
|
|
560
|
+
// manifest read before the walk is the one after it. A note changed after its turn is the next tick's to see, by
|
|
561
|
+
// its stat hint or the watcher, as one changed just after a walk that ran through is. The pending edits are then
|
|
562
|
+
// read, compared and written in one stretch, as before; the bytes an edit keeps are read there, not at the walk.
|
|
465
563
|
const basesOf = new Map()
|
|
466
564
|
const earlierLayout = new Set()
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
565
|
+
const looked = []
|
|
566
|
+
const vaultStartedMs = phaseTime()
|
|
567
|
+
try {
|
|
568
|
+
const budget = createCooperativeBudget()
|
|
569
|
+
for (const { scope, store } of scopes) {
|
|
570
|
+
const { manifest, bases } = trustedNoteBases(store)
|
|
571
|
+
basesOf.set(scope.scopeId, bases)
|
|
572
|
+
if (!manifest) continue
|
|
573
|
+
if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
|
|
574
|
+
const vault = await reconcileCooperatively({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat }, budget)
|
|
575
|
+
looked.push({ scope, store, manifest, bases, vault })
|
|
576
|
+
}
|
|
577
|
+
} finally { reportPhase('vault-observation', vaultStartedMs) }
|
|
578
|
+
const pending = stateStore.readPendingEdits()
|
|
579
|
+
let edits = pending.edits
|
|
580
|
+
for (const { scope, store, manifest, bases, vault } of looked) {
|
|
473
581
|
const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
|
|
474
582
|
const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
|
|
475
583
|
edits = observed.edits
|
|
476
|
-
// A note that has gone is
|
|
584
|
+
// A note that has gone is a change, once: the view is prepared again and its read-back says the note is missing.
|
|
585
|
+
// The publisher writes it back when it next publishes a generation of the view.
|
|
477
586
|
if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
|
|
478
587
|
}
|
|
479
588
|
stateStore.writePendingEdits({ ...pending, edits })
|
|
@@ -516,24 +625,33 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
516
625
|
const lateWriters = []
|
|
517
626
|
const lateDocument = stateStore.readLateWriters()
|
|
518
627
|
const knownFindings = new Set(lateDocument.findings.map((item) => `${item.scopeId}\u0000${item.journalId}\u0000${item.unit}\u0000${item.observedDigest}`))
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
628
|
+
const lateStartedMs = phaseTime()
|
|
629
|
+
try {
|
|
630
|
+
for (const { scope, store } of scopes) {
|
|
631
|
+
const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
|
|
632
|
+
if (journalIds.length === 0) continue
|
|
633
|
+
for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
|
|
634
|
+
if (finding.code !== 'late-writer-captured') continue
|
|
635
|
+
const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
|
|
636
|
+
if (knownFindings.has(key)) continue
|
|
637
|
+
knownFindings.add(key)
|
|
638
|
+
lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
|
|
639
|
+
}
|
|
528
640
|
}
|
|
529
|
-
}
|
|
641
|
+
} finally { reportPhase('late-writer-recheck', lateStartedMs) }
|
|
530
642
|
if (lateWriters.length > 0) {
|
|
531
643
|
const findings = [...lateDocument.findings, ...lateWriters].sort((left, right) => compareText(left.detectedAt, right.detectedAt) || compareText(left.journalId, right.journalId) || left.unit - right.unit || compareText(left.observedDigest, right.observedDigest))
|
|
532
644
|
stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
|
|
533
645
|
}
|
|
534
646
|
|
|
535
|
-
// 6. Sources, settings, scopes, eligibility.
|
|
536
|
-
|
|
647
|
+
// 6. Sources, settings, scopes, eligibility. The walk over the sources waits between files, as the walk over the
|
|
648
|
+
// vault does; at a full reconciliation it hashes every one. A source changed after its turn is the next tick's to
|
|
649
|
+
// see, by its stat hint or the watcher, or at the latest by the next full reconciliation. When this tick builds the
|
|
650
|
+
// graph, the look again at the sources after the build (`graph-source-recheck`) refuses such a change as
|
|
651
|
+
// `mixed-read`.
|
|
652
|
+
const sourcesStartedMs = phaseTime()
|
|
653
|
+
let sources
|
|
654
|
+
try { sources = await reconcileCooperatively({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat }, createCooperativeBudget()) } finally { reportPhase('source-observation', sourcesStartedMs) }
|
|
537
655
|
for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
|
|
538
656
|
const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
|
|
539
657
|
// What the configuration says apart from this integration's own member: a change to the member alone is an
|
|
@@ -599,37 +717,126 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
599
717
|
|
|
600
718
|
// 8. Rebuild and publish.
|
|
601
719
|
if (attempt.size > 0) {
|
|
720
|
+
// Cooperative preparation/publication can outlive the settings that
|
|
721
|
+
// selected this tick. Reuse the existing configuration/personal-input
|
|
722
|
+
// observation keys, machine settings and eligibility revision as the
|
|
723
|
+
// fence; do not invent another selection authority.
|
|
724
|
+
const bindings = configFilesOf(project).map(file => ({ ...file, digest: index.get(file.key)?.digest ?? null }))
|
|
725
|
+
const machineDigest = digestOfJson(machine)
|
|
726
|
+
const eligibilityRevision = String(eligibility.revision())
|
|
727
|
+
// The history's end is read once for a run of stopped views, however many views one change stops; a check that
|
|
728
|
+
// passes ends the run, so a change that stops a later view is read again.
|
|
729
|
+
let selectionNoted = false
|
|
730
|
+
const noteSelectionOnce = () => { if (!selectionNoted) { selectionNoted = true; rules.noteSelectionAtGuard(project) } }
|
|
731
|
+
const assertBindings = () => {
|
|
732
|
+
let changed
|
|
733
|
+
try {
|
|
734
|
+
changed = bindings.some(file => (readFileFacts(file.absolute)?.digest ?? null) !== file.digest)
|
|
735
|
+
|| digestOfJson(readMachineSettings({ workspaceRoot, workspaceId })) !== machineDigest
|
|
736
|
+
|| String(eligibility.revision()) !== eligibilityRevision
|
|
737
|
+
} catch (error) {
|
|
738
|
+
// A file that cannot be read now is not known to hold what this tick observed. The view is refused as one
|
|
739
|
+
// whose settings changed, and the next tick reads everything again: what an unreadable configuration file
|
|
740
|
+
// means is decided there, by observation and the project loader, as before.
|
|
741
|
+
if (!rules.guardReadFailed(error)) throw error
|
|
742
|
+
forceFull = true
|
|
743
|
+
// The file may be a selection record that cannot be read now (no permission, an I/O error, a file replaced while
|
|
744
|
+
// it was read): the history's end is read for the note.
|
|
745
|
+
noteSelectionOnce()
|
|
746
|
+
refuse('mixed-read', 'a file that decides source selection could not be read again while a view was being prepared or published', { cause: error.code })
|
|
747
|
+
}
|
|
748
|
+
if (changed) {
|
|
749
|
+
forceFull = true
|
|
750
|
+
// The change may be a confirmation in a personal home. The guard compares files and reads no history, so the
|
|
751
|
+
// history's end is read here for the note: a record that stopped this view counts from now on, also when it
|
|
752
|
+
// is gone again before the next tick looks.
|
|
753
|
+
noteSelectionOnce()
|
|
754
|
+
refuse('mixed-read', 'source selection or eligibility changed while a view was being prepared or published')
|
|
755
|
+
}
|
|
756
|
+
selectionNoted = false
|
|
757
|
+
}
|
|
602
758
|
let built = null
|
|
759
|
+
// Whether a selected source of a prepared view moved since this tick observed it, decided as observation
|
|
760
|
+
// decides (reconcile): a source that still has the stat hint it was indexed with is taken as unchanged, and one
|
|
761
|
+
// whose hint moved is read and must still be the pinned bytes. Bytes that drift under an unchanged hint stay
|
|
762
|
+
// within observation's own bound, as they do for the graph and for a reused note: the next full reconciliation
|
|
763
|
+
// sees them. It waits between sources, and the settings that selected the view are checked at each wait.
|
|
764
|
+
const assertSourcesUnmoved = async (prepared) => {
|
|
765
|
+
const startedMs = phaseTime()
|
|
766
|
+
try {
|
|
767
|
+
const budget = createCooperativeBudget({ guard: assertBindings })
|
|
768
|
+
budget.check()
|
|
769
|
+
const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
|
|
770
|
+
const selected = new Set(prepared.manifest.notes.map((note) => note.nodeId))
|
|
771
|
+
const attachments = new Set(prepared.manifest.attachments.map((item) => {
|
|
772
|
+
const source = item.ext?.[OBSIDIAN_EXT_KEY]
|
|
773
|
+
return source?.kind === 'embedded-asset' ? sourceKey(source.repoId, source.assetPath) : null
|
|
774
|
+
}))
|
|
775
|
+
const nodes = built.snapshot.graph.nodes.filter((node) => selected.has(node.id))
|
|
776
|
+
const assets = (built.snapshot.graph.assets ?? []).filter((asset) => attachments.has(sourceKey(asset.repo, asset.path)))
|
|
777
|
+
const pins = new Map(built.snapshot.document.repositories.flatMap((repo) => repo.files.map((file) => [sourceKey(repo.repoId, file.path), file])))
|
|
778
|
+
for (const source of [...nodes, ...assets]) {
|
|
779
|
+
await budget.checkpoint('commit-source-recheck')
|
|
780
|
+
const key = sourceKey(source.repo, source.path)
|
|
781
|
+
const root = roots.get(source.repo)
|
|
782
|
+
if (root !== undefined && hasIndexedHint(index.get(key), path.join(root, ...source.path.split('/')), lstat)) continue
|
|
783
|
+
const bytes = built.snapshot.readSource(source.repo, source.path)
|
|
784
|
+
const pin = pins.get(key)
|
|
785
|
+
if (!pin || !Buffer.isBuffer(bytes) || bytes.length !== pin.byteLength || sha256Digest(bytes) !== pin.rawDigest) refuse('mixed-read', 'a selected source changed while its view was being prepared or published; its generation is not committed')
|
|
786
|
+
}
|
|
787
|
+
budget.check()
|
|
788
|
+
} finally { reportPhase('commit-source-recheck', startedMs) }
|
|
789
|
+
}
|
|
603
790
|
try {
|
|
604
791
|
if (personalRefusal !== null) refuse(personalRefusal, 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
|
|
605
792
|
// A personal workspace whose validity key is not confirmed yet is composed off the event loop, then built again.
|
|
606
793
|
const build = () => seams.buildGraph({ project, eligibility, cache: graphCache, index, deferPersonalValidation: true })
|
|
607
794
|
let graph
|
|
608
|
-
try {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
795
|
+
try {
|
|
796
|
+
// Any other project is built with the event loop given a turn between the Markdown sources of the census: a
|
|
797
|
+
// first tick reads every source. The settings that select the views are checked at each wait, as during a
|
|
798
|
+
// preparation, and the look again at the sources after the build sees a source that moved during it.
|
|
799
|
+
if (typeof seams.buildGraphCooperatively === 'function' && personalWorkspaceBindingOf(project) === null) {
|
|
800
|
+
graph = await seams.buildGraphCooperatively({ project, eligibility, cache: graphCache, index, scheduling: { guard: assertBindings } })
|
|
801
|
+
} else try { graph = build() } catch (error) {
|
|
802
|
+
if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
|
|
803
|
+
const verdict = await validatePersonalWorkspace(project)
|
|
804
|
+
if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
|
|
805
|
+
try { graph = build() } catch (again) {
|
|
806
|
+
if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
|
|
807
|
+
refuse('personal-graph-unconfirmed', 'the graph built for the personal workspace is not the one its composition built; nothing is prepared from it', { source: 'personal-workspace' })
|
|
808
|
+
}
|
|
615
809
|
}
|
|
810
|
+
} catch (error) {
|
|
811
|
+
if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project, index })) refuse('mixed-read', 'a source was removed while the canonical graph was being built', { cause: error.code })
|
|
812
|
+
throw error
|
|
616
813
|
}
|
|
617
814
|
// The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
|
|
618
815
|
// what observation saw. A withheld asset is not observed: its bytes can change no view.
|
|
619
816
|
const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
|
|
620
817
|
observedAssets = rules.observedAssetsOf(graph)
|
|
621
818
|
const assetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
|
|
622
|
-
const settled = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat })
|
|
819
|
+
const settled = measured('graph-source-recheck', () => reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat }))
|
|
623
820
|
// An asset newly observed, or one no longer embedded, is the list changing. Anything else that moved since this tick looked at the sources moved while the graph was being read.
|
|
624
821
|
if (settled.changes.some((change) => !(change.kind === 'added' && assetKeys.has(change.key)) && !(change.kind === 'removed' && formerAssetKeys.has(change.key) && !assetKeys.has(change.key)))) throw new ObsidianMaintenanceRefusal('mixed-read', 'a source changed while the canonical graph was being built')
|
|
625
822
|
const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
|
|
823
|
+
const snapshot = seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now })
|
|
824
|
+
const observedProject = project
|
|
626
825
|
built = {
|
|
627
|
-
|
|
826
|
+
// A view's preparation, and the look again at its sources before the commit, read a source through the
|
|
827
|
+
// snapshot. A selected source removed after this tick observed it, read there, refuses the view as
|
|
828
|
+
// `mixed-read`, as a removal during the build does; the other views of the tick are still prepared.
|
|
829
|
+
snapshot: { ...snapshot, readSource: (repoId, relative) => {
|
|
830
|
+
try { return snapshot.readSource(repoId, relative) } catch (error) {
|
|
831
|
+
if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project: observedProject, index })) refuse('mixed-read', 'a selected source was removed while its view was being prepared or published', { cause: error.code })
|
|
832
|
+
throw error
|
|
833
|
+
}
|
|
834
|
+
} },
|
|
628
835
|
profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
|
|
629
836
|
}
|
|
630
837
|
} catch (error) {
|
|
631
838
|
if (!isTypedRefusal(error)) throw error
|
|
632
|
-
for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
|
|
839
|
+
for (const scopeId of attempt.keys()) { entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now)); noteRefusalCause(scopeId, error) }
|
|
633
840
|
if (REREAD_CODES.has(error.code)) forceFull = true
|
|
634
841
|
// The personal workspace refused the bound generation: the project is loaded again at the next tick, so a
|
|
635
842
|
// loader that now binds another generation is followed.
|
|
@@ -646,11 +853,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
646
853
|
}
|
|
647
854
|
try {
|
|
648
855
|
const held = heldPaths(edits, scopeId)
|
|
649
|
-
const prepared = seams.prepareView({
|
|
856
|
+
const prepared = await (seams.prepareViewCooperatively ?? seams.prepareView)({
|
|
650
857
|
snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
|
|
651
858
|
existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
|
|
652
859
|
heldNotePaths: held, layoutHeldNotePaths: layoutHeldOf(scopeId), viewScopeIds: scopes.map((item) => item.scope.scopeId),
|
|
860
|
+
// The cooperative preparation would read every selected source once more before it returns. The engine
|
|
861
|
+
// checks them itself, below and again before the commit, without opening a source observation would not.
|
|
862
|
+
scheduling: { guard: assertBindings, recheckSources: false },
|
|
653
863
|
})
|
|
864
|
+
assertBindings()
|
|
865
|
+
// Before anything is written: a source that moved while the view was being prepared refuses it here.
|
|
866
|
+
await assertSourcesUnmoved(prepared)
|
|
654
867
|
stateStore.writePathRegistry(prepared.persistentPathRegistry)
|
|
655
868
|
diagnostics = (prepared.manifest.ext?.[OBSIDIAN_EXT_KEY]?.diagnostics ?? []).slice(0, 100)
|
|
656
869
|
const preparedGenerationId = prepared.manifest.generationId
|
|
@@ -684,10 +897,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
684
897
|
}
|
|
685
898
|
let result
|
|
686
899
|
try {
|
|
900
|
+
// Once more while the publisher holds its locks, before it says the publication settled.
|
|
901
|
+
const beforeCommit = () => assertSourcesUnmoved(prepared)
|
|
687
902
|
result = await seams.publishView({
|
|
688
903
|
preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter, clock,
|
|
689
904
|
...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
|
|
690
905
|
...(typeof readUnheldEvidence === 'function' ? { unheldEvidence: readUnheldEvidence, platform } : {}),
|
|
906
|
+
scheduling: { guard: assertBindings }, beforeCommit,
|
|
691
907
|
})
|
|
692
908
|
// Stopped before its commit for want of an app that coordinates: the app's refusal is what the person has to
|
|
693
909
|
// act on, and `open` answers it before it would add the vault to the app.
|
|
@@ -705,15 +921,24 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
705
921
|
const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
|
|
706
922
|
if (result.state === 'refused') {
|
|
707
923
|
settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
|
|
924
|
+
noteRefusalCause(scopeId, result.refusal)
|
|
708
925
|
} else if (result.state === 'updating') {
|
|
709
926
|
const blocking = result.notes.filter((note) => note.blocking)
|
|
710
927
|
const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
|
|
711
928
|
settle(byEdit ? 'held-for-your-edit' : 'publisher-conflict', blocking[0]?.outcome ?? 'publication-incomplete', { ...common, heldNotes: [...new Set([...held, ...blocking.filter((note) => EDIT_OUTCOMES.has(note.outcome)).map((note) => note.path)])].sort(compareText) })
|
|
712
929
|
} else {
|
|
713
|
-
// Read back: every note of the committed generation, by digest.
|
|
714
|
-
|
|
930
|
+
// Read back: every note of the committed generation, by digest. The walk waits between notes, so a large
|
|
931
|
+
// view does not hold the service's listener for its whole length. Nothing is checked at those waits: the
|
|
932
|
+
// generation is committed, the view is reported as it was read, and what changes from here on is the next
|
|
933
|
+
// tick's to see, as it is after a read-back that ran through.
|
|
934
|
+
const readBackStartedMs = phaseTime()
|
|
935
|
+
let bases
|
|
936
|
+
try {
|
|
937
|
+
const committed = trustedNoteBases(store)
|
|
938
|
+
bases = committed.bases
|
|
939
|
+
await reconcileCooperatively({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest: committed.manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat }, createCooperativeBudget())
|
|
940
|
+
} finally { reportPhase('publication-read-back', readBackStartedMs) }
|
|
715
941
|
basesOf.set(scopeId, bases)
|
|
716
|
-
reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
|
|
717
942
|
const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
|
|
718
943
|
const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
|
|
719
944
|
if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
|
|
@@ -730,6 +955,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
730
955
|
const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
|
|
731
956
|
diagnostics = typeof where.rule === 'string' ? [{ code: error.code, rule: where.rule, ...(typeof where.notePath === 'string' ? { notePath: where.notePath } : {}), ...(typeof where.filePath === 'string' ? { filePath: where.filePath } : {}) }] : []
|
|
732
957
|
settle('stale', error.code)
|
|
958
|
+
noteRefusalCause(scopeId, error)
|
|
733
959
|
if (REREAD_CODES.has(error.code)) forceFull = true
|
|
734
960
|
}
|
|
735
961
|
}
|
|
@@ -748,7 +974,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
748
974
|
state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
|
|
749
975
|
changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
|
|
750
976
|
pendingEdits: edits.filter((edit) => edit.closedAt === null).map(({ editId, scopeId, path: notePath, state }) => ({ editId, scopeId, path: notePath, state })),
|
|
751
|
-
dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }),
|
|
977
|
+
dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }), ...(refusalCauses.length === 0 ? {} : { refusalCauses }),
|
|
752
978
|
}
|
|
753
979
|
}
|
|
754
980
|
|
|
@@ -770,7 +996,19 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
770
996
|
try {
|
|
771
997
|
return await runTick()
|
|
772
998
|
} catch (error) {
|
|
773
|
-
if (!isTypedRefusal(error))
|
|
999
|
+
if (!isTypedRefusal(error)) {
|
|
1000
|
+
// A tick that failed without a typed reason may have looked at files and recorded what it saw in the index
|
|
1001
|
+
// without acting on it: a note gone from a vault, a source that changed, or a configuration file that changed
|
|
1002
|
+
// before the project could be loaded again, is a change once, and the next look would find nothing to change.
|
|
1003
|
+
// The next tick therefore starts as the first tick of a process does: it loads the project again and looks
|
|
1004
|
+
// at every view, so whatever is missing or changed makes its view stale, or is published, not left current.
|
|
1005
|
+
if (rules.rechecksEveryViewAfterFailure) {
|
|
1006
|
+
project = null
|
|
1007
|
+
semantic = null
|
|
1008
|
+
firstTick = true
|
|
1009
|
+
}
|
|
1010
|
+
throw error
|
|
1011
|
+
}
|
|
774
1012
|
// Fail closed: nothing is published under a refusal, and every known view says why.
|
|
775
1013
|
try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
|
|
776
1014
|
project = null
|