@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.
- package/CHANGELOG.md +283 -0
- package/README.md +102 -52
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +197 -11
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +9 -9
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +49 -21
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +431 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- 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/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- 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 +96 -8
- package/src/projection/project.mjs +21 -16
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -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 +42 -11
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -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'
|
|
@@ -10,11 +12,12 @@ import { readObsidianEnablement } from './enablement.mjs'
|
|
|
10
12
|
import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
|
|
11
13
|
import { createMaintenanceExtensions } from './extension-points.mjs'
|
|
12
14
|
import {
|
|
13
|
-
assertOutsideRepositories, authorizeAutomaticApply,
|
|
14
|
-
readMachineSettings, resolveDataRoot, workspaceStateRoot,
|
|
15
|
+
assertOutsideRepositories, authorizeAutomaticApply, ensureWorkspaceIdentity, initializeMachineSettings, protectedRoots, readLocalPointer,
|
|
16
|
+
readMachineSettings, resolveDataRoot, workspaceStateRoot,
|
|
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'
|
|
20
|
+
import { personalExclusionRootsNow } from './personal-selection.mjs'
|
|
18
21
|
import { createProductionSeams, eligibilityFor } from './pipeline.mjs'
|
|
19
22
|
import { PLUGIN_FILES_WAIT_FOR_APP } from './plugin-presence.mjs'
|
|
20
23
|
import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
|
|
@@ -103,6 +106,15 @@ export const ENGINE_PRIMITIVES = Object.freeze({
|
|
|
103
106
|
},
|
|
104
107
|
// The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
|
|
105
108
|
observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
|
|
109
|
+
// A change the guard sees in the middle of a tick is read, where it may be a confirmed selection, while it is there.
|
|
110
|
+
noteSelectionAtGuard: notePersonalSelectionNow,
|
|
111
|
+
// A loaded project pinned to a selection different from the most recently accepted head in this process
|
|
112
|
+
// is loaded again at the next tick, whatever the files say: another reader in the process may have read a
|
|
113
|
+
// confirmation that is gone again before this engine looked (personalSelectionBehind).
|
|
114
|
+
reloadsBehindSelection: personalSelectionBehind,
|
|
115
|
+
// After a tick that failed without a typed reason, the next one looks at every view again, as the first tick of a
|
|
116
|
+
// process does: see tick().
|
|
117
|
+
rechecksEveryViewAfterFailure: true,
|
|
106
118
|
// One engine per workspace at a time, across processes.
|
|
107
119
|
acquireEngineLock: acquirePrivateGenerationLock,
|
|
108
120
|
// The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
|
|
@@ -126,9 +138,41 @@ export const ENGINE_PRIMITIVES = Object.freeze({
|
|
|
126
138
|
heldNoteDigest({ file }) {
|
|
127
139
|
try { return readFileFacts(file)?.digest ?? null } catch { return null }
|
|
128
140
|
},
|
|
141
|
+
// 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
|
|
142
|
+
// (no permission, an I/O error, no descriptor left), recognised as one is where a vault is placed. Anything else is a
|
|
143
|
+
// fault of the engine and stops the tick.
|
|
144
|
+
guardReadFailed: (error) => typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code),
|
|
145
|
+
// Whether an error met when the graph build, a view's preparation or the look again before its commit reads a
|
|
146
|
+
// source is a source, or a folder of sources, removed after this tick observed it: no such file (or a parent that is
|
|
147
|
+
// no longer a folder), at a path that is gone now and that this tick's walks over the sources (before the build, and
|
|
148
|
+
// the look again after it) recorded with a digest (a source of a repository the project observes), or a folder that
|
|
149
|
+
// held such a source. A source changed during the tick is refused as `mixed-read`; one removed is refused the same,
|
|
150
|
+
// and the next tick reads everything again. Anything else stops the tick, as before: a path still there, a path the
|
|
151
|
+
// walk listed and could not read (it was recorded without a digest), or one it never listed, so a path that can never
|
|
152
|
+
// be read still fails every tick.
|
|
153
|
+
sourceRemovedSinceObserved(error, { project, index }) {
|
|
154
|
+
if ((error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') || typeof error.path !== 'string' || !(index instanceof Map)) return false
|
|
155
|
+
const missing = path.resolve(error.path)
|
|
156
|
+
if (fs.existsSync(missing)) return false
|
|
157
|
+
for (const repo of project?.repos ?? []) {
|
|
158
|
+
if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
|
|
159
|
+
const relative = path.relative(path.resolve(repo.path), missing)
|
|
160
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) continue
|
|
161
|
+
const posix = relative.split(path.sep).join('/')
|
|
162
|
+
if (typeof index.get(sourceKey(repo.name, posix))?.digest === 'string') return true
|
|
163
|
+
const folder = sourceKey(repo.name, `${posix}/`)
|
|
164
|
+
for (const [key, entry] of index) if (key.startsWith(folder) && typeof entry?.digest === 'string') return true
|
|
165
|
+
}
|
|
166
|
+
return false
|
|
167
|
+
},
|
|
129
168
|
})
|
|
130
169
|
|
|
131
170
|
const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
|
|
171
|
+
// The private-state location proof refused (assertLocation's own code): the state root itself is no longer proven
|
|
172
|
+
// outside every root declared now. That is never one view's refusal: it stops the whole tick, so no later view goes
|
|
173
|
+
// on writing under roots it can no longer trust. A view's own vault in a bad place keeps its own codes and stops
|
|
174
|
+
// only that view.
|
|
175
|
+
const isLocationRefusal = (error) => isTypedRefusal(error) && error.code === 'managed-root-unproven'
|
|
132
176
|
// The code a view is refused with when its own vault cannot be placed: a typed refusal's, or `vault-location-unusable`
|
|
133
177
|
// for a file-system error there (a file in the way, no permission, a read-only volume). Null for anything else, which
|
|
134
178
|
// is a fault of the engine and stops the tick.
|
|
@@ -137,7 +181,7 @@ const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
|
|
|
137
181
|
// The configuration a tick observes. A project bound to a personal workspace adds the person's authored manifest and
|
|
138
182
|
// overlay and its generation's record: a change to any of them is a configuration change, so every view is prepared
|
|
139
183
|
// 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 }))]
|
|
184
|
+
export const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
|
|
141
185
|
|
|
142
186
|
// An editor adapter that `build` makes, synchronously or not, the first time the publisher calls it; a refusal of
|
|
143
187
|
// `build` is thrown from that call, and `refusal()` answers it afterwards.
|
|
@@ -160,6 +204,9 @@ function uncoordinatedAdapter(refusal) {
|
|
|
160
204
|
}
|
|
161
205
|
// Views a tick may be asked to prepare again, at most, before it runs.
|
|
162
206
|
const MAX_PREPARATION_REQUESTS = 64
|
|
207
|
+
// At most this many views a tick refuses for a file-system error are named with that error's code (`refusalCauses`); the
|
|
208
|
+
// others of that tick are not named.
|
|
209
|
+
const MAX_REFUSAL_CAUSES = 64
|
|
163
210
|
|
|
164
211
|
function journalTime(journalId) {
|
|
165
212
|
const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
|
|
@@ -198,13 +245,32 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
198
245
|
quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
|
|
199
246
|
// A service names where it answers health, so a lock it leaves behind can be proven abandoned.
|
|
200
247
|
lockOwner = null, lockProbe = probeHealth,
|
|
248
|
+
// Answers at once, without loading, what the workspace is resolved from (locatePersonalHome), for a loader that can
|
|
249
|
+
// refuse before any project was loaded: the views published before are then told why nothing is published now.
|
|
250
|
+
locateProject = null,
|
|
201
251
|
} = options
|
|
202
252
|
if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
|
|
203
253
|
if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
|
|
204
254
|
// No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
|
|
205
255
|
if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
|
|
206
|
-
const
|
|
256
|
+
const production = createProductionSeams()
|
|
257
|
+
const seams = { ...production, ...(options.seams ?? {}) }
|
|
258
|
+
// A caller that replaced prepareView and left the cooperative preparation as it ships prepares views its own way: its
|
|
259
|
+
// seam is used, synchronously, whether it handed in that one member or the production seams with that member replaced.
|
|
260
|
+
if (seams.prepareView !== production.prepareView && seams.prepareViewCooperatively === production.prepareViewCooperatively) seams.prepareViewCooperatively = null
|
|
261
|
+
// The same for the graph: a caller that replaced buildGraph and left the cooperative build as it ships builds its own way.
|
|
262
|
+
if (seams.buildGraph !== production.buildGraph && seams.buildGraphCooperatively === production.buildGraphCooperatively) seams.buildGraphCooperatively = null
|
|
207
263
|
const rules = { ...ENGINE_PRIMITIVES, ...primitives }
|
|
264
|
+
// Optional local qualification telemetry. It has no authority to change a
|
|
265
|
+
// tick's result, and records elapsed boundaries rather than a budget claim.
|
|
266
|
+
const phaseTime = () => performance.timeOrigin + performance.now()
|
|
267
|
+
const reportPhase = (phase, startedMs) => {
|
|
268
|
+
try { seams.onPhase?.({ phase, startedMs, endedMs: phaseTime() }) } catch { /* telemetry never changes custody */ }
|
|
269
|
+
}
|
|
270
|
+
const measured = (phase, operation) => {
|
|
271
|
+
const startedMs = phaseTime()
|
|
272
|
+
try { return operation() } finally { reportPhase(phase, startedMs) }
|
|
273
|
+
}
|
|
208
274
|
|
|
209
275
|
const index = new Map()
|
|
210
276
|
const hintedKeys = new Set()
|
|
@@ -227,8 +293,9 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
227
293
|
let firstTick = true
|
|
228
294
|
let running = false
|
|
229
295
|
let watching = { signature: null, handle: null }
|
|
230
|
-
let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
|
|
296
|
+
let known = null // { stateStore, maintenanceMode, repositoryRoots } once a workspace has been resolved
|
|
231
297
|
let heldLock = null
|
|
298
|
+
let releaseOwed = false
|
|
232
299
|
// Views somebody asked to have prepared and published once more, at the next tick.
|
|
233
300
|
const preparationRequests = new Set()
|
|
234
301
|
// The views the project declared at the last tick; null before the first.
|
|
@@ -249,19 +316,36 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
249
316
|
try { return (await readUnheldEvidence({ vaultRoot: store.vaultRoot }))?.unlisted === true } catch { return false }
|
|
250
317
|
}
|
|
251
318
|
|
|
252
|
-
async function takeLock(workspaceRoot, workspaceId) {
|
|
319
|
+
async function takeLock(workspaceRoot, workspaceId, validateLocation = undefined) {
|
|
320
|
+
// A lock an earlier tick could not give back is still this process's, and its ticket on disk unreleased: a tick
|
|
321
|
+
// that tried to take it anew would find its own ticket and answer `busy` until the process ends. It is given back
|
|
322
|
+
// first. A release that fails again fails this tick, and is owed to the next.
|
|
323
|
+
if (heldLock && releaseOwed) {
|
|
324
|
+
heldLock.release()
|
|
325
|
+
heldLock = null
|
|
326
|
+
releaseOwed = false
|
|
327
|
+
}
|
|
253
328
|
if (heldLock) return { acquired: true }
|
|
254
329
|
const lock = await rules.acquireEngineLock({
|
|
255
330
|
workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
|
|
331
|
+
...(validateLocation === undefined ? {} : { validateLocation }),
|
|
256
332
|
})
|
|
257
333
|
if (lock.acquired) heldLock = lock
|
|
258
334
|
return lock
|
|
259
335
|
}
|
|
260
336
|
|
|
337
|
+
// Gives the lock back at the end of a tick. One that cannot be given back (its released ticket could not be written)
|
|
338
|
+
// stays held, and the failure is the tick's: the next tick gives it back before anything else. One given back owes
|
|
339
|
+
// nothing more, whether this tick took it or an earlier one could not give it back.
|
|
261
340
|
function dropLock() {
|
|
262
341
|
const lock = heldLock
|
|
342
|
+
if (lock === null) return
|
|
343
|
+
try { lock.release() } catch (error) {
|
|
344
|
+
releaseOwed = true
|
|
345
|
+
throw error
|
|
346
|
+
}
|
|
263
347
|
heldLock = null
|
|
264
|
-
|
|
348
|
+
releaseOwed = false
|
|
265
349
|
}
|
|
266
350
|
|
|
267
351
|
function onWatcherEvent(roots, { rootId, relative }) {
|
|
@@ -292,7 +376,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
292
376
|
// A vault root that is gone (a folder under the data root the person removed, say) is looked for again too, so the
|
|
293
377
|
// store made in its place says why rather than failing on the folder that is not there.
|
|
294
378
|
if (cached?.signature === signature && fs.lstatSync(cached.store.vaultRoot, { throwIfNoEntry: false }) !== undefined) {
|
|
295
|
-
try { cached.store.checkAllocatedVault?.(); return cached.store } catch (error) { if (!isTypedRefusal(error)) throw error }
|
|
379
|
+
try { cached.store.checkAllocatedVault?.(); return cached.store } catch (error) { if (!isTypedRefusal(error) || isLocationRefusal(error)) throw error }
|
|
296
380
|
}
|
|
297
381
|
stores.delete(scope.scopeId)
|
|
298
382
|
const store = seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId: scope.scopeId, repositoryRoots })
|
|
@@ -315,20 +399,67 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
315
399
|
const blankEntry = (scopeId, now) => ({ scopeId, state: 'stale', reason: 'not-yet-published', generationId: null, preparedGenerationId: null, verified: false, heldNotes: [], changeClasses: [], retainedEdits: 0, checkedAt: now })
|
|
316
400
|
const demote = (entry, state, reason, now) => ({ ...entry, state, reason, verified: false, checkedAt: now })
|
|
317
401
|
|
|
318
|
-
//
|
|
319
|
-
|
|
402
|
+
// The private-state location guard's own refusals: under one, the state root itself is in question.
|
|
403
|
+
const MANAGED_ROOT_REFUSALS = new Set(['managed-root-inside-repository', 'managed-root-not-absolute', 'managed-root-realpath-failed',
|
|
404
|
+
'managed-root-realpath-overlap', 'managed-root-symlink-alias', 'repository-inside-managed-root', 'managed-root-unproven'])
|
|
405
|
+
// The roots private state must stay outside, as declared now: each project's own, and a personal home's manifest
|
|
406
|
+
// read again rather than as it was when the project loaded. Throws when they cannot be read.
|
|
407
|
+
const currentExclusionRoots = (declared) => declared.flatMap((value) => {
|
|
408
|
+
const binding = personalWorkspaceBindingOf(value)
|
|
409
|
+
return [...protectedRoots(value), ...(binding === null ? [] : personalExclusionRootsNow(binding.personalHome))]
|
|
410
|
+
})
|
|
411
|
+
// Whether a state root is proven outside every root declared now. A locator that refuses, or roots that cannot be
|
|
412
|
+
// read, prove nothing.
|
|
413
|
+
const locationProvenNow = (root, loaded) => {
|
|
414
|
+
try {
|
|
415
|
+
const declared = [...(locateProject === null ? [] : [locateProject()]), ...(loaded === null ? [] : [loaded])]
|
|
416
|
+
if (declared.length === 0) return false
|
|
417
|
+
assertOutsideRepositories({ managedRoot: root, repositoryRoots: currentExclusionRoots(declared) })
|
|
418
|
+
return true
|
|
419
|
+
} catch { return false }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state. Nothing is
|
|
423
|
+
// written for a location refusal, or unless the state root is proven outside every root declared now: a manifest
|
|
424
|
+
// that newly declares a root holding this state is never written into, cached store or not. When no project can be
|
|
425
|
+
// read now, a cached store is held to the roots it was last proven outside; without either, nothing is written.
|
|
426
|
+
async function persistOutcome({ enablement, state, reason, now, scopeIds = [], locationRefused = false }) {
|
|
427
|
+
if (locationRefused) return
|
|
428
|
+
// The roots declared now. A configured locator that cannot answer leaves them unknown: earlier roots never stand
|
|
429
|
+
// in. Without a locator or a readable project, a cached store is held to the roots it was last proven outside.
|
|
430
|
+
const rootsNow = () => {
|
|
431
|
+
let located = null
|
|
432
|
+
if (locateProject !== null) located = locateProject()
|
|
433
|
+
const declared = [located, project].filter((value) => value !== null && value !== undefined)
|
|
434
|
+
if (declared.length > 0) return currentExclusionRoots(declared)
|
|
435
|
+
if (locateProject === null && Array.isArray(known?.repositoryRoots)) return known.repositoryRoots
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
let repositoryRoots
|
|
439
|
+
try { repositoryRoots = rootsNow() } catch { return }
|
|
440
|
+
if (repositoryRoots === null) return
|
|
441
|
+
const outside = (root) => { try { assertOutsideRepositories({ managedRoot: root, repositoryRoots }); return true } catch { return false } }
|
|
442
|
+
const outsideNow = (root) => { try { const roots = rootsNow(); if (roots === null) return false; assertOutsideRepositories({ managedRoot: root, repositoryRoots: roots }); return true } catch { return false } }
|
|
320
443
|
if (!known) {
|
|
444
|
+
let source = project
|
|
445
|
+
if (locateProject !== null) { try { source = locateProject() } catch { return } }
|
|
446
|
+
if (source === null || source === undefined) return
|
|
321
447
|
let pointer = null
|
|
322
|
-
try { pointer =
|
|
448
|
+
try { pointer = readLocalPointer(source) } catch { pointer = null }
|
|
323
449
|
if (!pointer) return
|
|
324
450
|
let root
|
|
325
|
-
try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId) } catch { return }
|
|
451
|
+
try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project: source, env, platform }), pointer.workspaceId) } catch { return }
|
|
452
|
+
if (!outside(root)) return
|
|
326
453
|
const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
|
|
327
454
|
if (!stateStore.exists()) return
|
|
328
|
-
known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
|
|
455
|
+
known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual', repositoryRoots }
|
|
329
456
|
}
|
|
457
|
+
if (!outside(known.stateStore.workspaceRoot)) return
|
|
458
|
+
// Proven again inside the acquisition and once it returns, against the roots declared then.
|
|
459
|
+
const stillOutside = () => { if (!outsideNow(known.stateStore.workspaceRoot)) refuse('managed-root-unproven', 'the private state can no longer be proved outside every root declared now; nothing is written') }
|
|
330
460
|
// Another engine is ticking this workspace: its state is not ours to write.
|
|
331
|
-
if (!(await takeLock(known.stateStore.workspaceRoot, known.stateStore.workspaceId)).acquired) return
|
|
461
|
+
try { if (!(await takeLock(known.stateStore.workspaceRoot, known.stateStore.workspaceId, stillOutside)).acquired) return } catch (error) { if (isTypedRefusal(error) && error.code === 'managed-root-unproven') return; throw error }
|
|
462
|
+
if (!outsideNow(known.stateStore.workspaceRoot)) return
|
|
332
463
|
const previous = readPreviousFreshness(known.stateStore)
|
|
333
464
|
const entries = new Map()
|
|
334
465
|
for (const entry of previous?.scopes ?? []) entries.set(entry.scopeId, demote(entry, state, reason, now))
|
|
@@ -371,7 +502,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
371
502
|
if (personalWorkspaceBindingOf(project) === null) project = await loadProject()
|
|
372
503
|
} else {
|
|
373
504
|
const config = reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
|
|
374
|
-
if (config.changes.length > 0) {
|
|
505
|
+
if (config.changes.length > 0 || rules.reloadsBehindSelection(project)) {
|
|
375
506
|
project = await loadProject()
|
|
376
507
|
reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full: true, lstat })
|
|
377
508
|
}
|
|
@@ -387,20 +518,50 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
387
518
|
}
|
|
388
519
|
|
|
389
520
|
// 2. Identity and machine settings, outside every repository.
|
|
390
|
-
const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
|
|
391
|
-
const { workspaceId } = pointer
|
|
392
|
-
const requestedRoot = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), workspaceId)
|
|
393
521
|
const repositoryRoots = protectedRoots(project)
|
|
394
|
-
|
|
522
|
+
// The project this tick loaded. `project` itself may be cleared later in the tick to force a reload.
|
|
523
|
+
const loadedProject = project
|
|
524
|
+
// Each phase that writes after a cooperative wait proves the location again against the roots declared now; the
|
|
525
|
+
// refusal is a location refusal, so refusal bookkeeping writes nothing either. A held engine lock is still released.
|
|
526
|
+
const assertLocation = () => {
|
|
527
|
+
if (!locationProvenNow(workspaceRoot, loadedProject)) refuse('managed-root-unproven', 'the private state can no longer be proved outside every root declared now; this tick writes nothing more')
|
|
528
|
+
}
|
|
529
|
+
const stateRootOf = (named) => workspaceStateRoot(resolveDataRoot({ dataRoot, pointer: named, project, env, platform }), named.workspaceId)
|
|
530
|
+
const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}),
|
|
531
|
+
validate: (named) => assertOutsideRepositories({ managedRoot: stateRootOf(named), repositoryRoots }) })
|
|
532
|
+
const { workspaceId } = pointer
|
|
533
|
+
const requestedRoot = stateRootOf(pointer)
|
|
395
534
|
fs.mkdirSync(requestedRoot, { recursive: true, mode: 0o700 })
|
|
396
535
|
const workspaceRoot = fs.realpathSync(requestedRoot)
|
|
397
|
-
const lock = await takeLock(workspaceRoot, workspaceId)
|
|
536
|
+
const lock = await takeLock(workspaceRoot, workspaceId, assertLocation)
|
|
398
537
|
if (!lock.acquired) return { state: 'busy', reason: 'engine-lock-held', lock: { reason: lock.reason, holder: lock.holder ?? null }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
|
|
399
|
-
|
|
400
|
-
|
|
538
|
+
assertLocation()
|
|
539
|
+
const machine = initializeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, updatedAt: now, personal: personalWorkspaceBindingOf(project) !== null })
|
|
401
540
|
const eligibility = fixedEligibility ?? eligibilityFor({ machine, project })
|
|
402
541
|
const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
|
|
403
|
-
known = { stateStore, maintenanceMode: machine.maintenanceMode }
|
|
542
|
+
known = { stateStore, maintenanceMode: machine.maintenanceMode, repositoryRoots }
|
|
543
|
+
// A view refused for a file-system error (a vault folder that could not be allocated or placed, a configuration
|
|
544
|
+
// file that could not be read again, a source removed while it was read, candidates that could not be staged) is
|
|
545
|
+
// settled under the refusal's code alone, and such a tick ends without a failure. The error's code (`EACCES`, `EIO`,
|
|
546
|
+
// `ENOENT`), and that of a cleanup after it that failed too, are reported beside it for the service's log. Only
|
|
547
|
+
// codes shaped as a file-system error's are kept: never a message, which may name a path.
|
|
548
|
+
const refusalCauses = []
|
|
549
|
+
const errnoOf = (value) => (typeof value === 'string' && /^E[A-Z]+$/.test(value) ? value : null)
|
|
550
|
+
const noteRefusalCause = (scopeId, refusal) => {
|
|
551
|
+
const cause = errnoOf(refusal?.detail?.cause)
|
|
552
|
+
const cleanupCause = errnoOf(refusal?.detail?.cleanupCause)
|
|
553
|
+
if ((cause === null && cleanupCause === null) || refusalCauses.length >= MAX_REFUSAL_CAUSES) return
|
|
554
|
+
refusalCauses.push({ scopeId, code: refusal.code, ...(cause === null ? {} : { cause }), ...(cleanupCause === null ? {} : { cleanupCause }) })
|
|
555
|
+
}
|
|
556
|
+
// A view whose own vault cannot be placed: a typed refusal's code and detail, or `vault-location-unusable` for a
|
|
557
|
+
// file-system error there, whose code is its cause.
|
|
558
|
+
const refuseViewAlone = (scopeId, error) => {
|
|
559
|
+
if (isLocationRefusal(error)) throw error
|
|
560
|
+
const code = viewRefusalCode(error)
|
|
561
|
+
if (code === null) throw error
|
|
562
|
+
allocationRefusals.set(scopeId, code)
|
|
563
|
+
noteRefusalCause(scopeId, isTypedRefusal(error) ? error : { code, detail: { cause: error.code } })
|
|
564
|
+
}
|
|
404
565
|
// Each view's vault: allocated now where this workspace decided its vaults live, when it has none and was never
|
|
405
566
|
// published under the data root (vault-location.mjs). A view whose folder cannot be allocated is not prepared on
|
|
406
567
|
// this tick, and its freshness says why; the other views go on.
|
|
@@ -428,15 +589,12 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
428
589
|
const record = readVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId })
|
|
429
590
|
if (record === null ? !hasCommittedGeneration({ workspaceRoot, scopeId: scope.scopeId }) : allocatedFolderState(record) === 'missing') {
|
|
430
591
|
const known = await appList()
|
|
592
|
+
assertLocation()
|
|
431
593
|
if (!known.ok) refuse('app-vault-list-unreadable', 'Obsidian\'s vault list could not be read, so no folder is allocated for this view yet; it is tried again at the next tick', { cause: known.code })
|
|
432
594
|
vaults = known.vaults
|
|
433
595
|
}
|
|
434
596
|
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
|
-
}
|
|
597
|
+
} catch (error) { refuseViewAlone(scope.scopeId, error) }
|
|
440
598
|
}
|
|
441
599
|
}
|
|
442
600
|
// Each view's store, on its own: one whose vault cannot be placed (a record naming a folder inside a repository,
|
|
@@ -444,11 +602,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
444
602
|
const scopes = []
|
|
445
603
|
for (const scope of enablement.scopes) {
|
|
446
604
|
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
|
-
}
|
|
605
|
+
try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) { refuseViewAlone(scope.scopeId, error) }
|
|
452
606
|
}
|
|
453
607
|
|
|
454
608
|
watch([
|
|
@@ -460,20 +614,38 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
460
614
|
|
|
461
615
|
// 3. Vault notes: preserve, then queue. Compared with the bytes each note
|
|
462
616
|
// was generated with, not with the previous look.
|
|
463
|
-
|
|
464
|
-
|
|
617
|
+
//
|
|
618
|
+
// Every view's notes are looked at first, and the walk waits between notes, so a large vault does not hold the
|
|
619
|
+
// listener for its whole length (at a full reconciliation every note is hashed, and after a publication the
|
|
620
|
+
// watcher has named each note it wrote). Nothing is checked at those waits and nothing is written: within
|
|
621
|
+
// Atelier only this engine commits a manifest or writes the pending edits, under the lock this tick holds, so the
|
|
622
|
+
// manifest read before the walk is the one after it. A note changed after its turn is the next tick's to see, by
|
|
623
|
+
// its stat hint or the watcher, as one changed just after a walk that ran through is. The pending edits are then
|
|
624
|
+
// read, compared and written in one stretch, as before; the bytes an edit keeps are read there, not at the walk.
|
|
465
625
|
const basesOf = new Map()
|
|
466
626
|
const earlierLayout = new Set()
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
627
|
+
const looked = []
|
|
628
|
+
const vaultStartedMs = phaseTime()
|
|
629
|
+
try {
|
|
630
|
+
const budget = createCooperativeBudget()
|
|
631
|
+
for (const { scope, store } of scopes) {
|
|
632
|
+
const { manifest, bases } = trustedNoteBases(store)
|
|
633
|
+
basesOf.set(scope.scopeId, bases)
|
|
634
|
+
if (!manifest) continue
|
|
635
|
+
if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
|
|
636
|
+
const vault = await reconcileCooperatively({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat }, budget)
|
|
637
|
+
looked.push({ scope, store, manifest, bases, vault })
|
|
638
|
+
}
|
|
639
|
+
} finally { reportPhase('vault-observation', vaultStartedMs) }
|
|
640
|
+
assertLocation()
|
|
641
|
+
const pending = stateStore.readPendingEdits()
|
|
642
|
+
let edits = pending.edits
|
|
643
|
+
for (const { scope, store, manifest, bases, vault } of looked) {
|
|
473
644
|
const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
|
|
474
645
|
const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
|
|
475
646
|
edits = observed.edits
|
|
476
|
-
// A note that has gone is
|
|
647
|
+
// A note that has gone is a change, once: the view is prepared again and its read-back says the note is missing.
|
|
648
|
+
// The publisher writes it back when it next publishes a generation of the view.
|
|
477
649
|
if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
|
|
478
650
|
}
|
|
479
651
|
stateStore.writePendingEdits({ ...pending, edits })
|
|
@@ -490,6 +662,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
490
662
|
let observed = null
|
|
491
663
|
if (proposalAdapter !== null && rules.observeEdits(proposalAdapter)) {
|
|
492
664
|
try { observed = await proposalAdapter.observe(adapterContext(), { retryRefused: full }) } catch { observed = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
|
|
665
|
+
assertLocation()
|
|
493
666
|
}
|
|
494
667
|
|
|
495
668
|
// 4. Automatic apply, through the registered operation only.
|
|
@@ -499,6 +672,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
499
672
|
edits, applyOperation: extensions.applyOperation(), now, nowMs, retryIntervalMs,
|
|
500
673
|
authorize: () => rules.authorize({ workspaceRoot, workspaceId }),
|
|
501
674
|
})
|
|
675
|
+
assertLocation()
|
|
502
676
|
edits = outcome.edits
|
|
503
677
|
dispatched = outcome.dispatched
|
|
504
678
|
stateStore.writePendingEdits({ ...pending, edits })
|
|
@@ -510,30 +684,41 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
510
684
|
let proposals = null
|
|
511
685
|
if (proposalAdapter !== null) {
|
|
512
686
|
try { proposals = await proposalAdapter.propose(adapterContext()) } catch { proposals = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
|
|
687
|
+
assertLocation()
|
|
513
688
|
}
|
|
514
689
|
|
|
515
690
|
// 5. Late writers. The publisher looks twice per publication; a holder can write later still.
|
|
516
691
|
const lateWriters = []
|
|
517
692
|
const lateDocument = stateStore.readLateWriters()
|
|
518
693
|
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
|
-
|
|
694
|
+
const lateStartedMs = phaseTime()
|
|
695
|
+
try {
|
|
696
|
+
for (const { scope, store } of scopes) {
|
|
697
|
+
const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
|
|
698
|
+
if (journalIds.length === 0) continue
|
|
699
|
+
for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
|
|
700
|
+
if (finding.code !== 'late-writer-captured') continue
|
|
701
|
+
const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
|
|
702
|
+
if (knownFindings.has(key)) continue
|
|
703
|
+
knownFindings.add(key)
|
|
704
|
+
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 })
|
|
705
|
+
}
|
|
528
706
|
}
|
|
529
|
-
}
|
|
707
|
+
} finally { reportPhase('late-writer-recheck', lateStartedMs) }
|
|
530
708
|
if (lateWriters.length > 0) {
|
|
531
709
|
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
710
|
stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
|
|
533
711
|
}
|
|
534
712
|
|
|
535
|
-
// 6. Sources, settings, scopes, eligibility.
|
|
536
|
-
|
|
713
|
+
// 6. Sources, settings, scopes, eligibility. The walk over the sources waits between files, as the walk over the
|
|
714
|
+
// vault does; at a full reconciliation it hashes every one. A source changed after its turn is the next tick's to
|
|
715
|
+
// see, by its stat hint or the watcher, or at the latest by the next full reconciliation. When this tick builds the
|
|
716
|
+
// graph, the look again at the sources after the build (`graph-source-recheck`) refuses such a change as
|
|
717
|
+
// `mixed-read`.
|
|
718
|
+
const sourcesStartedMs = phaseTime()
|
|
719
|
+
let sources
|
|
720
|
+
try { sources = await reconcileCooperatively({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat }, createCooperativeBudget()) } finally { reportPhase('source-observation', sourcesStartedMs) }
|
|
721
|
+
assertLocation()
|
|
537
722
|
for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
|
|
538
723
|
const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
|
|
539
724
|
// What the configuration says apart from this integration's own member: a change to the member alone is an
|
|
@@ -594,42 +779,135 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
594
779
|
entries.set(scopeId, demote(previous?.scopes.find((entry) => entry.scopeId === scopeId && entry.state !== 'disabled') ?? blankEntry(scopeId, now), 'stale', code, now))
|
|
595
780
|
}
|
|
596
781
|
for (const entry of previous?.scopes ?? []) if (!entries.has(entry.scopeId)) entries.set(entry.scopeId, demote(entry, 'disabled', 'scope-not-configured', now))
|
|
597
|
-
|
|
782
|
+
// Freshness is written only while the state root is still proven outside the roots declared now: a manifest that
|
|
783
|
+
// changed during this tick to enclose it gets no write, refusal or not.
|
|
784
|
+
const persist = () => { if (locationProvenNow(workspaceRoot, loadedProject)) stateStore.writeFreshness(freshnessDocument({ workspaceId, enablement: 'enabled', maintenanceMode: machine.maintenanceMode, now, entries })) }
|
|
598
785
|
persist()
|
|
599
786
|
|
|
600
787
|
// 8. Rebuild and publish.
|
|
601
788
|
if (attempt.size > 0) {
|
|
789
|
+
// Cooperative preparation/publication can outlive the settings that
|
|
790
|
+
// selected this tick. Reuse the existing configuration/personal-input
|
|
791
|
+
// observation keys, machine settings and eligibility revision as the
|
|
792
|
+
// fence; do not invent another selection authority.
|
|
793
|
+
const bindings = configFilesOf(project).map(file => ({ ...file, digest: index.get(file.key)?.digest ?? null }))
|
|
794
|
+
const machineDigest = digestOfJson(machine)
|
|
795
|
+
const eligibilityRevision = String(eligibility.revision())
|
|
796
|
+
// The history's end is read once for a run of stopped views, however many views one change stops; a check that
|
|
797
|
+
// passes ends the run, so a change that stops a later view is read again.
|
|
798
|
+
let selectionNoted = false
|
|
799
|
+
const noteSelectionOnce = () => { if (!selectionNoted) { selectionNoted = true; rules.noteSelectionAtGuard(project) } }
|
|
800
|
+
const assertBindings = () => {
|
|
801
|
+
// Every cooperative wait of preparation and publication resumes here before it writes again.
|
|
802
|
+
assertLocation()
|
|
803
|
+
let changed
|
|
804
|
+
try {
|
|
805
|
+
changed = bindings.some(file => (readFileFacts(file.absolute)?.digest ?? null) !== file.digest)
|
|
806
|
+
|| digestOfJson(readMachineSettings({ workspaceRoot, workspaceId })) !== machineDigest
|
|
807
|
+
|| String(eligibility.revision()) !== eligibilityRevision
|
|
808
|
+
} catch (error) {
|
|
809
|
+
// A file that cannot be read now is not known to hold what this tick observed. The view is refused as one
|
|
810
|
+
// whose settings changed, and the next tick reads everything again: what an unreadable configuration file
|
|
811
|
+
// means is decided there, by observation and the project loader, as before.
|
|
812
|
+
if (!rules.guardReadFailed(error)) throw error
|
|
813
|
+
forceFull = true
|
|
814
|
+
// The file may be a selection record that cannot be read now (no permission, an I/O error, a file replaced while
|
|
815
|
+
// it was read): the history's end is read for the note.
|
|
816
|
+
noteSelectionOnce()
|
|
817
|
+
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 })
|
|
818
|
+
}
|
|
819
|
+
if (changed) {
|
|
820
|
+
forceFull = true
|
|
821
|
+
// The change may be a confirmation in a personal home. The guard compares files and reads no history, so the
|
|
822
|
+
// history's end is read here for the note: a record that stopped this view counts from now on, also when it
|
|
823
|
+
// is gone again before the next tick looks.
|
|
824
|
+
noteSelectionOnce()
|
|
825
|
+
refuse('mixed-read', 'source selection or eligibility changed while a view was being prepared or published')
|
|
826
|
+
}
|
|
827
|
+
selectionNoted = false
|
|
828
|
+
}
|
|
602
829
|
let built = null
|
|
830
|
+
// Whether a selected source of a prepared view moved since this tick observed it, decided as observation
|
|
831
|
+
// decides (reconcile): a source that still has the stat hint it was indexed with is taken as unchanged, and one
|
|
832
|
+
// whose hint moved is read and must still be the pinned bytes. Bytes that drift under an unchanged hint stay
|
|
833
|
+
// within observation's own bound, as they do for the graph and for a reused note: the next full reconciliation
|
|
834
|
+
// sees them. It waits between sources, and the settings that selected the view are checked at each wait.
|
|
835
|
+
const assertSourcesUnmoved = async (prepared) => {
|
|
836
|
+
const startedMs = phaseTime()
|
|
837
|
+
try {
|
|
838
|
+
const budget = createCooperativeBudget({ guard: assertBindings })
|
|
839
|
+
budget.check()
|
|
840
|
+
const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
|
|
841
|
+
const selected = new Set(prepared.manifest.notes.map((note) => note.nodeId))
|
|
842
|
+
const attachments = new Set(prepared.manifest.attachments.map((item) => {
|
|
843
|
+
const source = item.ext?.[OBSIDIAN_EXT_KEY]
|
|
844
|
+
return source?.kind === 'embedded-asset' ? sourceKey(source.repoId, source.assetPath) : null
|
|
845
|
+
}))
|
|
846
|
+
const nodes = built.snapshot.graph.nodes.filter((node) => selected.has(node.id))
|
|
847
|
+
const assets = (built.snapshot.graph.assets ?? []).filter((asset) => attachments.has(sourceKey(asset.repo, asset.path)))
|
|
848
|
+
const pins = new Map(built.snapshot.document.repositories.flatMap((repo) => repo.files.map((file) => [sourceKey(repo.repoId, file.path), file])))
|
|
849
|
+
for (const source of [...nodes, ...assets]) {
|
|
850
|
+
await budget.checkpoint('commit-source-recheck')
|
|
851
|
+
const key = sourceKey(source.repo, source.path)
|
|
852
|
+
const root = roots.get(source.repo)
|
|
853
|
+
if (root !== undefined && hasIndexedHint(index.get(key), path.join(root, ...source.path.split('/')), lstat)) continue
|
|
854
|
+
const bytes = built.snapshot.readSource(source.repo, source.path)
|
|
855
|
+
const pin = pins.get(key)
|
|
856
|
+
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')
|
|
857
|
+
}
|
|
858
|
+
budget.check()
|
|
859
|
+
} finally { reportPhase('commit-source-recheck', startedMs) }
|
|
860
|
+
}
|
|
603
861
|
try {
|
|
604
862
|
if (personalRefusal !== null) refuse(personalRefusal, 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
|
|
605
863
|
// A personal workspace whose validity key is not confirmed yet is composed off the event loop, then built again.
|
|
606
864
|
const build = () => seams.buildGraph({ project, eligibility, cache: graphCache, index, deferPersonalValidation: true })
|
|
607
865
|
let graph
|
|
608
|
-
try {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
866
|
+
try {
|
|
867
|
+
// Any other project is built with the event loop given a turn between the Markdown sources of the census: a
|
|
868
|
+
// first tick reads every source. The settings that select the views are checked at each wait, as during a
|
|
869
|
+
// preparation, and the look again at the sources after the build sees a source that moved during it.
|
|
870
|
+
if (typeof seams.buildGraphCooperatively === 'function' && personalWorkspaceBindingOf(project) === null) {
|
|
871
|
+
graph = await seams.buildGraphCooperatively({ project, eligibility, cache: graphCache, index, scheduling: { guard: assertBindings } })
|
|
872
|
+
} else try { graph = build() } catch (error) {
|
|
873
|
+
if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
|
|
874
|
+
const verdict = await validatePersonalWorkspace(project)
|
|
875
|
+
if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
|
|
876
|
+
try { graph = build() } catch (again) {
|
|
877
|
+
if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
|
|
878
|
+
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' })
|
|
879
|
+
}
|
|
615
880
|
}
|
|
881
|
+
} catch (error) {
|
|
882
|
+
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 })
|
|
883
|
+
throw error
|
|
616
884
|
}
|
|
617
885
|
// The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
|
|
618
886
|
// what observation saw. A withheld asset is not observed: its bytes can change no view.
|
|
619
887
|
const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
|
|
620
888
|
observedAssets = rules.observedAssetsOf(graph)
|
|
621
889
|
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 })
|
|
890
|
+
const settled = measured('graph-source-recheck', () => reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat }))
|
|
623
891
|
// 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
892
|
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
893
|
const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
|
|
894
|
+
const snapshot = seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now })
|
|
895
|
+
const observedProject = project
|
|
626
896
|
built = {
|
|
627
|
-
|
|
897
|
+
// A view's preparation, and the look again at its sources before the commit, read a source through the
|
|
898
|
+
// snapshot. A selected source removed after this tick observed it, read there, refuses the view as
|
|
899
|
+
// `mixed-read`, as a removal during the build does; the other views of the tick are still prepared.
|
|
900
|
+
snapshot: { ...snapshot, readSource: (repoId, relative) => {
|
|
901
|
+
try { return snapshot.readSource(repoId, relative) } catch (error) {
|
|
902
|
+
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 })
|
|
903
|
+
throw error
|
|
904
|
+
}
|
|
905
|
+
} },
|
|
628
906
|
profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
|
|
629
907
|
}
|
|
630
908
|
} catch (error) {
|
|
631
|
-
if (!isTypedRefusal(error)) throw error
|
|
632
|
-
for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
|
|
909
|
+
if (!isTypedRefusal(error) || isLocationRefusal(error)) throw error
|
|
910
|
+
for (const scopeId of attempt.keys()) { entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now)); noteRefusalCause(scopeId, error) }
|
|
633
911
|
if (REREAD_CODES.has(error.code)) forceFull = true
|
|
634
912
|
// The personal workspace refused the bound generation: the project is loaded again at the next tick, so a
|
|
635
913
|
// loader that now binds another generation is followed.
|
|
@@ -638,7 +916,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
638
916
|
for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
|
|
639
917
|
const { scopeId } = scope
|
|
640
918
|
const entry = entries.get(scopeId)
|
|
641
|
-
// What this attempt found worth naming: notes and rules (see "Notes that were laid out anyway" in docs/obsidian-contract.md).
|
|
919
|
+
// What this attempt found worth naming: notes and rules (see "Notes that were laid out anyway" in docs/reference/obsidian-contract.md).
|
|
642
920
|
let diagnostics = []
|
|
643
921
|
const settle = (state, reason, extra = {}) => {
|
|
644
922
|
const { diagnostics: _earlier, ...rest } = { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now }
|
|
@@ -646,11 +924,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
646
924
|
}
|
|
647
925
|
try {
|
|
648
926
|
const held = heldPaths(edits, scopeId)
|
|
649
|
-
const prepared = seams.prepareView({
|
|
927
|
+
const prepared = await (seams.prepareViewCooperatively ?? seams.prepareView)({
|
|
650
928
|
snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
|
|
651
929
|
existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
|
|
652
930
|
heldNotePaths: held, layoutHeldNotePaths: layoutHeldOf(scopeId), viewScopeIds: scopes.map((item) => item.scope.scopeId),
|
|
931
|
+
// The cooperative preparation would read every selected source once more before it returns. The engine
|
|
932
|
+
// checks them itself, below and again before the commit, without opening a source observation would not.
|
|
933
|
+
scheduling: { guard: assertBindings, recheckSources: false },
|
|
653
934
|
})
|
|
935
|
+
assertBindings()
|
|
936
|
+
// Before anything is written: a source that moved while the view was being prepared refuses it here.
|
|
937
|
+
await assertSourcesUnmoved(prepared)
|
|
654
938
|
stateStore.writePathRegistry(prepared.persistentPathRegistry)
|
|
655
939
|
diagnostics = (prepared.manifest.ext?.[OBSIDIAN_EXT_KEY]?.diagnostics ?? []).slice(0, 100)
|
|
656
940
|
const preparedGenerationId = prepared.manifest.generationId
|
|
@@ -684,10 +968,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
684
968
|
}
|
|
685
969
|
let result
|
|
686
970
|
try {
|
|
971
|
+
// Once more while the publisher holds its locks, before it says the publication settled.
|
|
972
|
+
const beforeCommit = () => assertSourcesUnmoved(prepared)
|
|
687
973
|
result = await seams.publishView({
|
|
688
974
|
preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter, clock,
|
|
689
975
|
...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
|
|
690
976
|
...(typeof readUnheldEvidence === 'function' ? { unheldEvidence: readUnheldEvidence, platform } : {}),
|
|
977
|
+
scheduling: { guard: assertBindings }, beforeCommit,
|
|
691
978
|
})
|
|
692
979
|
// Stopped before its commit for want of an app that coordinates: the app's refusal is what the person has to
|
|
693
980
|
// act on, and `open` answers it before it would add the vault to the app.
|
|
@@ -705,15 +992,24 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
705
992
|
const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
|
|
706
993
|
if (result.state === 'refused') {
|
|
707
994
|
settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
|
|
995
|
+
noteRefusalCause(scopeId, result.refusal)
|
|
708
996
|
} else if (result.state === 'updating') {
|
|
709
997
|
const blocking = result.notes.filter((note) => note.blocking)
|
|
710
998
|
const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
|
|
711
999
|
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
1000
|
} else {
|
|
713
|
-
// Read back: every note of the committed generation, by digest.
|
|
714
|
-
|
|
1001
|
+
// Read back: every note of the committed generation, by digest. The walk waits between notes, so a large
|
|
1002
|
+
// view does not hold the service's listener for its whole length. Nothing is checked at those waits: the
|
|
1003
|
+
// generation is committed, the view is reported as it was read, and what changes from here on is the next
|
|
1004
|
+
// tick's to see, as it is after a read-back that ran through.
|
|
1005
|
+
const readBackStartedMs = phaseTime()
|
|
1006
|
+
let bases
|
|
1007
|
+
try {
|
|
1008
|
+
const committed = trustedNoteBases(store)
|
|
1009
|
+
bases = committed.bases
|
|
1010
|
+
await reconcileCooperatively({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest: committed.manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat }, createCooperativeBudget())
|
|
1011
|
+
} finally { reportPhase('publication-read-back', readBackStartedMs) }
|
|
715
1012
|
basesOf.set(scopeId, bases)
|
|
716
|
-
reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
|
|
717
1013
|
const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
|
|
718
1014
|
const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
|
|
719
1015
|
if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
|
|
@@ -726,10 +1022,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
726
1022
|
}
|
|
727
1023
|
} catch (error) {
|
|
728
1024
|
if (!isTypedRefusal(error)) { settle('stale', 'publisher-error'); persist(); throw error }
|
|
1025
|
+
// Nothing more is recorded for any view: the tick stops and refuses as a whole.
|
|
1026
|
+
if (isLocationRefusal(error)) throw error
|
|
729
1027
|
// A redaction refusal names the note and the rule it concerns, never the value.
|
|
730
1028
|
const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
|
|
731
1029
|
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
1030
|
settle('stale', error.code)
|
|
1031
|
+
noteRefusalCause(scopeId, error)
|
|
733
1032
|
if (REREAD_CODES.has(error.code)) forceFull = true
|
|
734
1033
|
}
|
|
735
1034
|
}
|
|
@@ -748,7 +1047,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
748
1047
|
state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
|
|
749
1048
|
changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
|
|
750
1049
|
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 }),
|
|
1050
|
+
dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }), ...(refusalCauses.length === 0 ? {} : { refusalCauses }),
|
|
752
1051
|
}
|
|
753
1052
|
}
|
|
754
1053
|
|
|
@@ -770,9 +1069,21 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
|
|
|
770
1069
|
try {
|
|
771
1070
|
return await runTick()
|
|
772
1071
|
} catch (error) {
|
|
773
|
-
if (!isTypedRefusal(error))
|
|
1072
|
+
if (!isTypedRefusal(error)) {
|
|
1073
|
+
// A tick that failed without a typed reason may have looked at files and recorded what it saw in the index
|
|
1074
|
+
// without acting on it: a note gone from a vault, a source that changed, or a configuration file that changed
|
|
1075
|
+
// before the project could be loaded again, is a change once, and the next look would find nothing to change.
|
|
1076
|
+
// The next tick therefore starts as the first tick of a process does: it loads the project again and looks
|
|
1077
|
+
// at every view, so whatever is missing or changed makes its view stale, or is published, not left current.
|
|
1078
|
+
if (rules.rechecksEveryViewAfterFailure) {
|
|
1079
|
+
project = null
|
|
1080
|
+
semantic = null
|
|
1081
|
+
firstTick = true
|
|
1082
|
+
}
|
|
1083
|
+
throw error
|
|
1084
|
+
}
|
|
774
1085
|
// Fail closed: nothing is published under a refusal, and every known view says why.
|
|
775
|
-
try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
|
|
1086
|
+
try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock), locationRefused: MANAGED_ROOT_REFUSALS.has(error.code) }) } catch { /* the refusal itself is still reported */ }
|
|
776
1087
|
project = null
|
|
777
1088
|
semantic = null
|
|
778
1089
|
firstTick = true
|