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