@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,6 +1,8 @@
|
|
|
1
|
+
import { createCooperativeBudget, prepareViewCooperatively } from '../../projection/obsidian/materialize/prepare-view.mjs'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
3
|
+
import fs from 'node:fs'
|
|
2
4
|
import path from 'node:path'
|
|
3
|
-
import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
|
|
5
|
+
import { buildCanonicalGraph, buildCanonicalGraphSteps, createGraphFileCache } from '../../graph/graph.mjs'
|
|
4
6
|
import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
|
|
5
7
|
import { EMITTER_VERSION, createPreparationCache, prepareView, readMarkdownLens, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
|
|
6
8
|
import { personalWorkspaceGraph } from '../../projection/obsidian/personal-workspace.mjs'
|
|
@@ -128,9 +130,99 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
|
|
|
128
130
|
// `deferPersonalValidation` (the engine), an unconfirmed key refuses `personal-validation-pending` instead of composing
|
|
129
131
|
// on this thread.
|
|
130
132
|
export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
|
|
131
|
-
const observedDigest = cache
|
|
132
|
-
|
|
133
|
-
if (
|
|
133
|
+
const observedDigest = observedDigestOf(cache, index)
|
|
134
|
+
let canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
|
|
135
|
+
if (canonical === null) {
|
|
136
|
+
try { canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest }) } catch (error) {
|
|
137
|
+
refuseObservedDirectory(error, { project, index })
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return eligibleGraph(canonical, eligibility, { project, index })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The same graph, built with the event loop given a turn between the Markdown sources of the census, under the
|
|
145
|
+
// caller's cooperative budget (`scheduling`: createCooperativeBudget's options, its guard checked at every wait). For
|
|
146
|
+
// the maintenance engine's tick: a first tick reads every source, and a large corpus would hold the service's
|
|
147
|
+
// listener for the whole census. A project bound to a personal workspace is built by its own route, in one stretch,
|
|
148
|
+
// as buildGraph builds it.
|
|
149
|
+
export async function buildGraphCooperatively({ project, eligibility, cache = null, index = null, deferPersonalValidation = false, scheduling = {} }) {
|
|
150
|
+
const observedDigest = observedDigestOf(cache, index)
|
|
151
|
+
// The personal route answers for a bound project, and refuses a project that carries the reserved key unbound.
|
|
152
|
+
const personal = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
|
|
153
|
+
if (personal !== null) return eligibleGraph(personal, eligibility, { project, index })
|
|
154
|
+
const budget = createCooperativeBudget(scheduling)
|
|
155
|
+
budget.check()
|
|
156
|
+
const steps = buildCanonicalGraphSteps(project, { fileCache: cache, observedDigest })
|
|
157
|
+
let canonical
|
|
158
|
+
try {
|
|
159
|
+
for (;;) {
|
|
160
|
+
const step = steps.next()
|
|
161
|
+
if (step.done) { canonical = step.value; break }
|
|
162
|
+
await budget.checkpoint(step.value)
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
steps.return?.()
|
|
166
|
+
refuseObservedDirectory(error, { project, index })
|
|
167
|
+
throw error
|
|
168
|
+
}
|
|
169
|
+
budget.finish('graph-assembled')
|
|
170
|
+
return eligibleGraph(canonical, eligibility, { project, index })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const observedDigestOf = (cache, index) => (cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null)
|
|
174
|
+
|
|
175
|
+
// Only a digest from this tick's observation establishes that the path was a readable source before the build.
|
|
176
|
+
// These exceptional checks never enrol a path or admit an invalid graph; without prior observation its error stays.
|
|
177
|
+
function* observedSources({ project, index }, changeClass = null) {
|
|
178
|
+
if (!(index instanceof Map)) return
|
|
179
|
+
for (const repo of project?.repos ?? []) {
|
|
180
|
+
if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
|
|
181
|
+
const root = path.resolve(repo.path)
|
|
182
|
+
const prefix = sourceKey(repo.name, '')
|
|
183
|
+
for (const [key, entry] of index) {
|
|
184
|
+
if (typeof key !== 'string' || !key.startsWith(prefix) || typeof entry?.digest !== 'string' || (changeClass !== null && entry.changeClass !== changeClass)) continue
|
|
185
|
+
const relative = key.slice(prefix.length)
|
|
186
|
+
const file = path.resolve(root, ...relative.split('/'))
|
|
187
|
+
const inside = path.relative(root, file)
|
|
188
|
+
if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue
|
|
189
|
+
yield file
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function directoryNow(file) {
|
|
195
|
+
try { return fs.lstatSync(file).isDirectory() } catch { return false }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function refuseObservedDirectory(error, observed) {
|
|
199
|
+
if (error?.code !== 'EISDIR') return
|
|
200
|
+
// readFileSync's EISDIR need not name a path. In that case an observed file that is now a directory still proves
|
|
201
|
+
// this build spans two source states; the next full observation decides what the directory now contributes.
|
|
202
|
+
const named = typeof error.path === 'string' ? path.resolve(error.path) : null
|
|
203
|
+
for (const file of observedSources(observed)) {
|
|
204
|
+
if ((named === null || named === file) && directoryNow(file)) refuse('mixed-read', 'an observed source became a directory while the canonical graph was being built', { cause: 'EISDIR' })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function eligibleGraph(canonical, eligibility, observed) {
|
|
209
|
+
if (!canonical.ok) {
|
|
210
|
+
// The graph records a sidecar read failure as invalid metadata. A sidecar this tick already read and that is
|
|
211
|
+
// gone now is instead a mixed read, as a source removed during the census is. A never-observed sidecar stays invalid.
|
|
212
|
+
const roots = new Map((observed.project?.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
|
|
213
|
+
const sidecars = new Set((canonical.nodes ?? []).flatMap((node) => typeof node.sidecar === 'string' && roots.has(node.repo)
|
|
214
|
+
? [path.resolve(roots.get(node.repo), ...node.sidecar.split('/'))] : []))
|
|
215
|
+
for (const file of observedSources(observed, 'sidecar')) {
|
|
216
|
+
if (!sidecars.has(file)) continue
|
|
217
|
+
let stat
|
|
218
|
+
try { stat = fs.lstatSync(file) } catch (error) {
|
|
219
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') refuse('mixed-read', 'an observed sidecar was removed while the canonical graph was being built', { cause: error.code })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (stat.isDirectory()) refuse('mixed-read', 'an observed sidecar became a directory while the canonical graph was being built', { cause: 'EISDIR' })
|
|
223
|
+
}
|
|
224
|
+
refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
|
|
225
|
+
}
|
|
134
226
|
return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
|
|
135
227
|
}
|
|
136
228
|
|
|
@@ -141,6 +233,12 @@ export function buildGraph({ project, eligibility, cache = null, index = null, d
|
|
|
141
233
|
// its pinned digest is unchanged and the cached bytes are the ones that digest
|
|
142
234
|
// describes, so bytes that drift under an unchanged pin are not consulted
|
|
143
235
|
// until observation hashes the file again (the same bound as the graph stage).
|
|
236
|
+
// The engine keeps that bound when it prepares cooperatively. Called on its
|
|
237
|
+
// own, prepareViewCooperatively waits between notes and so reads every
|
|
238
|
+
// selected source against its pin once more before it returns. The engine
|
|
239
|
+
// turns that second reading off and checks the selected sources itself, after
|
|
240
|
+
// the preparation and again before the commit, as observation does: by stat
|
|
241
|
+
// hint, and by digest only where the hint moved.
|
|
144
242
|
export function captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt }) {
|
|
145
243
|
const roots = new Map((project.repos ?? []).filter((repo) => !repo.external).map((repo) => [repo.name, repo.path]))
|
|
146
244
|
const absolute = (repoId, relative) => {
|
|
@@ -149,11 +247,13 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
|
|
|
149
247
|
return path.join(root, ...relative.split('/'))
|
|
150
248
|
}
|
|
151
249
|
const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
|
|
250
|
+
const observedReads = new Set()
|
|
152
251
|
// Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
|
|
153
252
|
const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
|
|
154
253
|
for (const item of pinned) {
|
|
155
254
|
if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
|
|
156
255
|
const observed = index.get(sourceKey(item.repo, item.path))
|
|
256
|
+
if (typeof observed?.digest === 'string') observedReads.add(sourceKey(item.repo, item.path))
|
|
157
257
|
const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
|
|
158
258
|
if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
|
|
159
259
|
byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
|
|
@@ -176,7 +276,16 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
|
|
|
176
276
|
repositories,
|
|
177
277
|
},
|
|
178
278
|
graph,
|
|
179
|
-
readSource: (repoId, relative) =>
|
|
279
|
+
readSource: (repoId, relative) => {
|
|
280
|
+
const file = absolute(repoId, relative)
|
|
281
|
+
try { return readFileBytes(file) } catch (error) {
|
|
282
|
+
if (observedReads.has(sourceKey(repoId, relative))) {
|
|
283
|
+
if (error?.code === 'ELEAFCHANGED') refuse('mixed-read', 'a selected source was replaced while its view was being prepared or published', { cause: error.code })
|
|
284
|
+
if ((error?.code === 'EISDIR' || error?.message === 'state leaf is not a regular file') && directoryNow(file)) refuse('mixed-read', 'a selected source became a directory while its view was being prepared or published', { cause: 'EISDIR' })
|
|
285
|
+
}
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
},
|
|
180
289
|
}
|
|
181
290
|
}
|
|
182
291
|
|
|
@@ -186,5 +295,5 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
|
|
|
186
295
|
// that note and reuses the rest. Both caches are derived, in-memory state: a
|
|
187
296
|
// test may replace either seam with `() => null` to build or prepare in full.
|
|
188
297
|
export function createProductionSeams() {
|
|
189
|
-
return { buildGraph, captureSnapshot, profileFor, prepareView, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
|
|
298
|
+
return { buildGraph, buildGraphCooperatively, captureSnapshot, profileFor, prepareView, prepareViewCooperatively, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
|
|
190
299
|
}
|
|
@@ -31,7 +31,7 @@ import { ObsidianMaintenanceRefusal } from './errors.mjs'
|
|
|
31
31
|
// No answer in time proves nothing (a busy holder answers late). A ticket
|
|
32
32
|
// from another machine, an unreadable ticket, an unknown file, or a live
|
|
33
33
|
// process that recorded no health address all need a person: see
|
|
34
|
-
// `inspectPrivateGenerationLock` and docs/local-services.md.
|
|
34
|
+
// `inspectPrivateGenerationLock` and docs/reference/local-services.md.
|
|
35
35
|
|
|
36
36
|
export const LOCK_TICKET_SCHEMA = 'atelier-obsidian-lock-ticket/v1'
|
|
37
37
|
export const ENGINE_LOCK_DIRECTORY = path.join('state', 'maintenance', 'engine-lock')
|
|
@@ -89,6 +89,20 @@ function listGenerations(directory) {
|
|
|
89
89
|
|
|
90
90
|
const ticketName = (generation, kind) => `${String(generation).padStart(12, '0')}.${kind}`
|
|
91
91
|
|
|
92
|
+
// A ticket is removed only while it holds the given bytes: one that holds others is another holder's. Answers whether
|
|
93
|
+
// nothing of these bytes is left at the path (removed now, or gone already); throws what reading or removing it threw.
|
|
94
|
+
function removeIfStill(file, body) {
|
|
95
|
+
let current
|
|
96
|
+
try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return true; throw error }
|
|
97
|
+
if (current !== body) return true
|
|
98
|
+
fs.unlinkSync(file)
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Tickets this process published and could not remove after its acquisition failed, by lock directory. Each is
|
|
103
|
+
// removed at this process's next acquisition in that directory, before the directory is looked at.
|
|
104
|
+
const UNWITHDRAWN = new Map()
|
|
105
|
+
|
|
92
106
|
// Read-only. { available, reason, generation, ticket }
|
|
93
107
|
export async function inspectPrivateGenerationLock({ directory, workspaceId, proveAbandoned = createAbandonmentProof(), nowMs = Date.now() }) {
|
|
94
108
|
const { names, highest, unknown } = listGenerations(directory)
|
|
@@ -114,42 +128,72 @@ export async function inspectPrivateGenerationLock({ directory, workspaceId, pro
|
|
|
114
128
|
|
|
115
129
|
// { acquired: true, generation, release() } or { acquired: false, reason, holder }.
|
|
116
130
|
// Never waits and never throws for a lock that is merely held.
|
|
117
|
-
|
|
131
|
+
// `validateLocation`, when given, proves the private state's location before the lock directory is made, again after
|
|
132
|
+
// the asynchronous inspection, and once a ticket is published, before predecessors are pruned. A refusal at the first
|
|
133
|
+
// two writes nothing; a refusal at the last withdraws the ticket just published, and prunes nothing.
|
|
134
|
+
export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes, validateLocation = () => {} }) {
|
|
135
|
+
validateLocation()
|
|
118
136
|
const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
|
|
137
|
+
const owed = UNWITHDRAWN.get(contained)
|
|
138
|
+
if (owed !== undefined) {
|
|
139
|
+
// Still failing: the ticket stays, and this acquisition finds it as held by this process, as before.
|
|
140
|
+
try { if (removeIfStill(owed.file, owed.body)) UNWITHDRAWN.delete(contained) } catch { /* tried again at the next acquisition */ }
|
|
141
|
+
}
|
|
119
142
|
const acquiredAt = isoTime(clock)
|
|
120
143
|
const status = await inspectPrivateGenerationLock({ directory: contained, workspaceId, proveAbandoned, nowMs: Date.parse(acquiredAt) })
|
|
121
144
|
const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
|
|
122
145
|
if (!status.available) return { acquired: false, reason: status.reason, holder }
|
|
123
146
|
if (status.generation >= MAX_GENERATION) return { acquired: false, reason: 'lock-generations-exhausted', holder }
|
|
147
|
+
validateLocation()
|
|
124
148
|
const generation = status.generation + 1
|
|
125
149
|
const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
|
|
126
150
|
const body = JSON.stringify(ticket)
|
|
127
151
|
const file = path.join(contained, ticketName(generation, 'json'))
|
|
152
|
+
// A ticket published and then not handed to its caller, because a later step failed (the directory sync or the
|
|
153
|
+
// read-back of the publication, the listing that confirms it), is removed while it still holds these bytes: nobody
|
|
154
|
+
// would release it, and every later acquisition by this process would find it held by this process. One that does
|
|
155
|
+
// not hold them is another holder's and is left alone. A removal that fails too (the same fault, say) is owed, and
|
|
156
|
+
// made at the next acquisition in this directory.
|
|
157
|
+
const withdraw = () => {
|
|
158
|
+
try { removeIfStill(file, body) } catch { UNWITHDRAWN.set(contained, { file, body }) }
|
|
159
|
+
}
|
|
128
160
|
try { publishPrivateFile(file, body) } catch (error) {
|
|
129
161
|
if (error.code === 'EEXIST') return { acquired: false, reason: 'lost-the-race-for-this-generation', holder: null }
|
|
162
|
+
withdraw()
|
|
130
163
|
throw error
|
|
131
164
|
}
|
|
132
165
|
// Confirm: a decision made on an old listing finds the newer ticket here and withdraws.
|
|
133
|
-
|
|
166
|
+
let after
|
|
167
|
+
try { after = listGenerations(contained) } catch (error) {
|
|
168
|
+
withdraw()
|
|
169
|
+
throw error
|
|
170
|
+
}
|
|
134
171
|
if (after.highest !== generation) {
|
|
135
172
|
try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
|
|
136
173
|
return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
|
|
137
174
|
}
|
|
175
|
+
try { validateLocation() } catch (error) {
|
|
176
|
+
withdraw()
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
138
179
|
for (const name of after.names) {
|
|
139
180
|
if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
|
|
140
181
|
}
|
|
182
|
+
// Settled once the lock is given back, or found not to be ours any more. A release that throws (a full disk, an
|
|
183
|
+
// I/O error while the released ticket is written) settles nothing: the lock is still held, and calling release()
|
|
184
|
+
// again tries again. Writing the same released ticket a second time is harmless.
|
|
141
185
|
let released = false
|
|
142
186
|
return {
|
|
143
187
|
acquired: true,
|
|
144
188
|
generation,
|
|
145
189
|
release() {
|
|
146
190
|
if (released) return false
|
|
147
|
-
released = true
|
|
148
191
|
let current
|
|
149
|
-
try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return false
|
|
192
|
+
try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') { released = true; return false } throw error }
|
|
150
193
|
// Taken over after a proof of abandonment (this process was thought gone): nothing here is ours to release.
|
|
151
|
-
if (current !== body) return false
|
|
194
|
+
if (current !== body) { released = true; return false }
|
|
152
195
|
publishPrivateFile(path.join(contained, ticketName(generation, 'released')), body)
|
|
196
|
+
released = true
|
|
153
197
|
return true
|
|
154
198
|
},
|
|
155
199
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { inspectPrivateLock, withPrivateLock } from '../../project/durable-state.mjs'
|
|
5
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow } from '../../project/private-state.mjs'
|
|
6
|
+
import { canonicalJson, closedObject } from './documents.mjs'
|
|
7
|
+
import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
|
|
8
|
+
|
|
9
|
+
// Machine-private continuity evidence, separate from the person's append-only selection history. The maximum never
|
|
10
|
+
// decreases, and previously observed record identities remain known after the person confirms a shorter history.
|
|
11
|
+
export const SELECTION_CONTINUITY_SCHEMA = 'atelier-obsidian-selection-continuity/v1'
|
|
12
|
+
export const MAX_SELECTION_IDENTITIES = 8192
|
|
13
|
+
export const MAX_SELECTION_CONTINUITY_BYTES = 1024 * 1024
|
|
14
|
+
const DIGEST = /^[0-9a-f]{64}$/
|
|
15
|
+
const CONFIRMATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
16
|
+
const SOURCE = Object.freeze({ source: 'personal-workspace' })
|
|
17
|
+
const invalid = () => refuse('invalid-selection-continuity', 'the private selection continuity state cannot be verified; nothing is loaded', SOURCE)
|
|
18
|
+
const count = (value) => Number.isSafeInteger(value) && value >= 0
|
|
19
|
+
|
|
20
|
+
function validate(document, workspaceId, homeDigest) {
|
|
21
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'homeDigest', 'highestSequence', 'accepted', 'seen', 'confirmations'] }, 'invalid-selection-continuity', 'the selection continuity state')
|
|
22
|
+
closedObject(document.accepted, { required: ['sequence', 'head'] }, 'invalid-selection-continuity', 'the accepted selection')
|
|
23
|
+
if (document.schema !== SELECTION_CONTINUITY_SCHEMA || document.workspaceId !== workspaceId || document.homeDigest !== homeDigest
|
|
24
|
+
|| !count(document.highestSequence) || !count(document.accepted.sequence) || document.accepted.sequence > document.highestSequence
|
|
25
|
+
|| !Array.isArray(document.seen) || document.seen.length > MAX_SELECTION_IDENTITIES || document.seen.some((digest) => typeof digest !== 'string' || !DIGEST.test(digest))
|
|
26
|
+
|| !Array.isArray(document.confirmations) || document.confirmations.length > MAX_SELECTION_IDENTITIES || document.confirmations.some((id) => typeof id !== 'string' || !CONFIRMATION_ID.test(id))
|
|
27
|
+
|| new Set(document.confirmations).size !== document.confirmations.length
|
|
28
|
+
|| new Set(document.seen).size !== document.seen.length || document.highestSequence > document.seen.length
|
|
29
|
+
|| (document.accepted.sequence === 0 ? document.accepted.head !== 'genesis' : !document.seen.includes(document.accepted.head))) invalid()
|
|
30
|
+
return document
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createSelectionContinuity({ workspaceRoot, workspaceId, personalHome, validateLocation = () => {} }) {
|
|
34
|
+
const homeDigest = createHash('sha256').update(personalHome).digest('hex')
|
|
35
|
+
const directory = () => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'maintenance'), label: 'selection continuity state' })
|
|
36
|
+
const file = path.join(workspaceRoot, 'state', 'maintenance', 'selection-continuity.json')
|
|
37
|
+
const lockPath = path.join(workspaceRoot, 'state', 'maintenance', 'selection-continuity.lock')
|
|
38
|
+
let observedState = false
|
|
39
|
+
let pending = null // an observed head whose durable write has not yet been proved
|
|
40
|
+
// The durable accepted head that pending was planned over. Pending keeps its maxima and identities, but never its
|
|
41
|
+
// accepted head once another writer has durably accepted something else.
|
|
42
|
+
let pendingBase = null
|
|
43
|
+
function read({ initializing = false } = {}) {
|
|
44
|
+
directory()
|
|
45
|
+
let fd
|
|
46
|
+
try {
|
|
47
|
+
fd = openRegularFileNoFollow(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0))
|
|
48
|
+
if (fs.fstatSync(fd).size > MAX_SELECTION_CONTINUITY_BYTES) invalid()
|
|
49
|
+
const bytes = fs.readFileSync(fd)
|
|
50
|
+
if (bytes.length > MAX_SELECTION_CONTINUITY_BYTES) invalid()
|
|
51
|
+
const document = validate(JSON.parse(bytes), workspaceId, homeDigest)
|
|
52
|
+
observedState = true
|
|
53
|
+
return document
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT' && !observedState) {
|
|
56
|
+
const ownership = inspectPrivateLock(lockPath)
|
|
57
|
+
const unused = ownership.available && ownership.reason === 'unused' && ownership.generation === 0
|
|
58
|
+
// Only called inside withPrivateLock's operation: generation one is this operation's own first ticket.
|
|
59
|
+
const ownFirstTicket = initializing && !ownership.available && ownership.reason === 'process exists' && ownership.generation === 1
|
|
60
|
+
if (ownership.legacyDigest === null && (initializing ? ownFirstTicket : unused)) return null
|
|
61
|
+
}
|
|
62
|
+
invalid()
|
|
63
|
+
} finally { if (fd !== undefined) fs.closeSync(fd) }
|
|
64
|
+
}
|
|
65
|
+
function next(before, head) {
|
|
66
|
+
const seen = new Set(before?.seen ?? [])
|
|
67
|
+
const confirmations = new Set(before?.confirmations ?? [])
|
|
68
|
+
const same = before?.accepted.sequence === head.sequence && before?.accepted.head === head.head
|
|
69
|
+
// Length alone never proves continuation: the verified chain must still hold the accepted head at its position.
|
|
70
|
+
const extendsAccepted = before === null || before.accepted.sequence === 0
|
|
71
|
+
|| (head.sequence > before.accepted.sequence && head.recordDigests[before.accepted.sequence - 1] === before.accepted.head)
|
|
72
|
+
const shorter = before !== null && (head.sequence < before.highestSequence || head.sequence <= before.accepted.sequence || !extendsAccepted)
|
|
73
|
+
const fresh = head.confirmationId !== null && !confirmations.has(head.confirmationId) && !seen.has(head.head)
|
|
74
|
+
if (!same && shorter && !fresh) refuse('selection-history-shortened', 'the selection history no longer follows the private continuity state; restore the removed records or confirm the selection again', SOURCE)
|
|
75
|
+
for (const digest of head.recordDigests) seen.add(digest)
|
|
76
|
+
for (const id of head.confirmationIds) confirmations.add(id)
|
|
77
|
+
if (seen.size > MAX_SELECTION_IDENTITIES || confirmations.size > MAX_SELECTION_IDENTITIES) refuse('selection-continuity-capacity', 'the private selection continuity state has reached its identity limit; nothing is loaded', SOURCE)
|
|
78
|
+
return validate({ schema: SELECTION_CONTINUITY_SCHEMA, workspaceId, homeDigest,
|
|
79
|
+
highestSequence: Math.max(before?.highestSequence ?? 0, head.sequence), accepted: { sequence: head.sequence, head: head.head }, seen: [...seen].sort(), confirmations: [...confirmations].sort() }, workspaceId, homeDigest)
|
|
80
|
+
}
|
|
81
|
+
function withPending(current) {
|
|
82
|
+
if (pending === null) return current
|
|
83
|
+
if (current === null) return pending
|
|
84
|
+
const seen = [...new Set([...current.seen, ...pending.seen])].sort()
|
|
85
|
+
const confirmations = [...new Set([...current.confirmations, ...pending.confirmations])].sort()
|
|
86
|
+
if (seen.length > MAX_SELECTION_IDENTITIES || confirmations.length > MAX_SELECTION_IDENTITIES) refuse('selection-continuity-capacity', 'the private selection continuity state has reached its identity limit; nothing is loaded', SOURCE)
|
|
87
|
+
const durableMoved = canonicalJson(current.accepted) !== pendingBase
|
|
88
|
+
return { ...pending, highestSequence: Math.max(current.highestSequence, pending.highestSequence), seen, confirmations,
|
|
89
|
+
accepted: durableMoved || current.highestSequence > pending.highestSequence ? current.accepted : pending.accepted }
|
|
90
|
+
}
|
|
91
|
+
function note(head) {
|
|
92
|
+
try {
|
|
93
|
+
validateLocation()
|
|
94
|
+
const disk = read()
|
|
95
|
+
const before = withPending(disk)
|
|
96
|
+
const planned = next(before, head)
|
|
97
|
+
if (disk !== null && canonicalJson(disk) === canonicalJson(planned)) { pending = null; pendingBase = null; return head }
|
|
98
|
+
if (pending === null || (disk !== null && canonicalJson(disk.accepted) !== pendingBase)) pendingBase = disk === null ? null : canonicalJson(disk.accepted)
|
|
99
|
+
pending = planned
|
|
100
|
+
// Proven again before the lock is taken and before the checkpoint is replaced.
|
|
101
|
+
validateLocation()
|
|
102
|
+
withPrivateLock(lockPath, () => {
|
|
103
|
+
const current = read({ initializing: disk === null })
|
|
104
|
+
// Decided again over the checkpoint as it reads under the lock.
|
|
105
|
+
const document = next(withPending(current), head)
|
|
106
|
+
pendingBase = current === null ? null : canonicalJson(current.accepted)
|
|
107
|
+
pending = document
|
|
108
|
+
const text = canonicalJson(document)
|
|
109
|
+
if (Buffer.byteLength(text) > MAX_SELECTION_CONTINUITY_BYTES) invalid()
|
|
110
|
+
validateLocation()
|
|
111
|
+
if (current === null || canonicalJson(current) !== text) atomicReplacePrivateText(file, text)
|
|
112
|
+
// An uncertain write is never authority to return a project.
|
|
113
|
+
if (canonicalJson(read()) !== text) invalid()
|
|
114
|
+
})
|
|
115
|
+
pending = null
|
|
116
|
+
pendingBase = null
|
|
117
|
+
return head
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof ObsidianMaintenanceRefusal) throw error
|
|
120
|
+
refuse('selection-continuity-unavailable', 'the private selection continuity state could not be kept; nothing is loaded', SOURCE)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze({ note })
|
|
124
|
+
}
|
|
@@ -4,12 +4,17 @@ import path from 'node:path'
|
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
|
|
6
6
|
import { workspaceStateRoot } from './machine-settings.mjs'
|
|
7
|
-
import {
|
|
7
|
+
import { loadSelectedProjectOffThread, locatePersonalHome } from './personal-selection.mjs'
|
|
8
|
+
import { RELEASE_CHANGED, isLocationRefusalCode, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
|
|
8
9
|
|
|
9
10
|
// The process of the maintenance service. `start` runs it as a child; a unit
|
|
10
11
|
// installed at the operating-system level runs it with `--startup`.
|
|
11
12
|
//
|
|
12
|
-
// --project=<absolute atelier.project.json> required
|
|
13
|
+
// --project=<absolute atelier.project.json> required, unless a personal home is named instead
|
|
14
|
+
// --personal-home=<absolute directory> instead of --project: the private home of a personal workspace. The
|
|
15
|
+
// service follows the generation its person confirmed there, composed off
|
|
16
|
+
// the event loop, and starts whether or not one is confirmed: it locates
|
|
17
|
+
// its workspace from the home alone (personal-selection.mjs)
|
|
13
18
|
// --data-root=<absolute directory> optional; otherwise the pointer, the overlay or the platform default
|
|
14
19
|
// --workspace-id=<identifier> named by a login item: under `--startup`, where a refusal is recorded
|
|
15
20
|
// when the project no longer leads to a workspace
|
|
@@ -77,17 +82,54 @@ export const EXIT_REFUSED = 2
|
|
|
77
82
|
// manager restarts it (a non-zero exit), on the release now installed. EX_TEMPFAIL.
|
|
78
83
|
export const EXIT_RELEASE_CHANGED = 75
|
|
79
84
|
|
|
85
|
+
const readOr = (read, otherwise) => { try { return read() } catch { return otherwise } }
|
|
80
86
|
const printed = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
|
|
81
87
|
|
|
88
|
+
// The first answer of the loader, or of the locator of a personal home, asked for once and handed to both who need it
|
|
89
|
+
// at a start under a login item: the lookup of the workspace below, and the service's own start, which takes it as the
|
|
90
|
+
// answer to its first call. One that answers a promise or another thenable is not waited for by either. A promise whose
|
|
91
|
+
// `constructor` reads as this realm's `Promise` is kept as it is; any other thenable (as a rule another realm's promise
|
|
92
|
+
// or a subclass's) is adopted here, once, as a native promise (so its `then` is called once in all). That promise is let go, handled
|
|
93
|
+
// without calling a `then` of its own: a refusal of it never goes unhandled and ends the process with an error where
|
|
94
|
+
// the service's typed refusal should end it cleanly. The service is handed that promise. Whatever is thrown,
|
|
95
|
+
// `undefined` included, and a `then` that cannot be read, is thrown again at the service's first call, so it ends the
|
|
96
|
+
// start as a failure of the loader would.
|
|
97
|
+
function firstAnswerOf(loadProject) {
|
|
98
|
+
let first
|
|
99
|
+
try {
|
|
100
|
+
const project = loadProject()
|
|
101
|
+
if (typeof project?.then === 'function') {
|
|
102
|
+
const promised = Promise.resolve(project)
|
|
103
|
+
Promise.prototype.then.call(promised, undefined, () => {})
|
|
104
|
+
first = { promised }
|
|
105
|
+
} else first = { project }
|
|
106
|
+
} catch (error) { first = { threw: true, error } }
|
|
107
|
+
let taken = false
|
|
108
|
+
return {
|
|
109
|
+
// The project, when the loader answered one at once; null when it threw or answered a promise.
|
|
110
|
+
project: () => (first.threw || first.promised ? null : first.project),
|
|
111
|
+
threw: () => first.threw === true,
|
|
112
|
+
loadProject() {
|
|
113
|
+
if (taken) return loadProject()
|
|
114
|
+
taken = true
|
|
115
|
+
if (first.threw) throw first.error
|
|
116
|
+
return first.promised ?? first.project
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
82
121
|
// The workspace of a service a login item started, when it can be found: where it keeps its log and records how its
|
|
83
|
-
// start ended. When the project no longer leads to one (it moved, its pointer is gone
|
|
84
|
-
// its data root and identity, if that exists; it is never created here. Null
|
|
85
|
-
//
|
|
86
|
-
|
|
122
|
+
// start ended. When the project no longer leads to one (it moved, its pointer is gone, its loader answered no project
|
|
123
|
+
// at once), the workspace the unit names by its data root and identity, if that exists; it is never created here. Null
|
|
124
|
+
// otherwise, and null when locating the workspace refused or a personal home's locator refused: the recorded
|
|
125
|
+
// coordinates are not proven outside the roots declared now. A refusal is then printed only, into the unit's own
|
|
126
|
+
// output file.
|
|
127
|
+
function startupWorkspace({ project, located = false, locatorRefused = false, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
|
|
128
|
+
if (located && locatorRefused) return null
|
|
87
129
|
try {
|
|
88
|
-
const workspace = resolveServiceWorkspace({ project
|
|
130
|
+
const workspace = project === null ? null : resolveServiceWorkspace({ project, dataRoot, env, platform })
|
|
89
131
|
if (workspace?.workspaceRoot) return workspace
|
|
90
|
-
} catch {
|
|
132
|
+
} catch { return null }
|
|
91
133
|
if (typeof dataRoot !== 'string' || !path.isAbsolute(dataRoot) || typeof workspaceId !== 'string') return null
|
|
92
134
|
try {
|
|
93
135
|
const root = workspaceStateRoot(path.resolve(dataRoot), workspaceId)
|
|
@@ -120,10 +162,14 @@ function recordStartup(workspace, { at, outcome, code }) {
|
|
|
120
162
|
// and the manager restarts it after its throttle.
|
|
121
163
|
export async function runServiceProcess(options) {
|
|
122
164
|
const startup = options.startup === true
|
|
123
|
-
|
|
165
|
+
// A located personal home is asked instead of the loader: the loader is then not asked before the service's engine.
|
|
166
|
+
const located = typeof options.locateProject === 'function'
|
|
167
|
+
const asked = located ? options.locateProject : options.loadProject
|
|
168
|
+
const first = startup && typeof asked === 'function' ? firstAnswerOf(asked) : null
|
|
169
|
+
const workspace = startup ? startupWorkspace({ ...options, located, locatorRefused: first?.threw() === true, project: first === null ? null : first.project() }) : null
|
|
124
170
|
const log = workspace === null ? printed : workspaceLog(workspace.workspaceRoot)
|
|
125
171
|
try {
|
|
126
|
-
const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options })
|
|
172
|
+
const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options, ...(first === null ? {} : { [located ? 'locateProject' : 'loadProject']: first.loadProject }) })
|
|
127
173
|
if (startup) recordStartup(workspace, { at: new Date().toISOString(), outcome: 'started', code: null })
|
|
128
174
|
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
|
|
129
175
|
const { reason } = await service.done
|
|
@@ -131,9 +177,12 @@ export async function runServiceProcess(options) {
|
|
|
131
177
|
process.exit(reason === RELEASE_CHANGED ? EXIT_RELEASE_CHANGED : 0)
|
|
132
178
|
} catch (error) {
|
|
133
179
|
const at = new Date().toISOString()
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
180
|
+
// What was thrown is read without trusting it: a value with no prototype, or a getter that throws, is still logged.
|
|
181
|
+
const code = readOr(() => (typeof error?.code === 'string' ? error.code : null), null)
|
|
182
|
+
// A location refusal is printed only: nothing more is written under that private-state root.
|
|
183
|
+
const location = isLocationRefusalCode(code)
|
|
184
|
+
;(location ? printed : log)({ at, event: 'refused', code: code ?? 'untyped-error', message: readOr(() => String(error?.message ?? error), 'unprintable') })
|
|
185
|
+
if (startup && code !== null) { if (!location) recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
|
|
137
186
|
process.exitCode = code !== null ? EXIT_REFUSED : 1
|
|
138
187
|
}
|
|
139
188
|
}
|
|
@@ -149,13 +198,16 @@ function watchRelease(invokedAs) {
|
|
|
149
198
|
export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
|
|
150
199
|
const args = parseArgs(argv)
|
|
151
200
|
const configPath = firstString(args.project)
|
|
152
|
-
|
|
201
|
+
const personalHome = firstString(args['personal-home'])
|
|
202
|
+
if (personalHome !== null && (configPath !== null || !path.isAbsolute(personalHome))) throw Object.assign(new Error('--personal-home must be the absolute path of a private home, given instead of --project'), { code: 'service-arguments-invalid' })
|
|
203
|
+
if (personalHome === null && (!configPath || !path.isAbsolute(configPath))) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
|
|
153
204
|
const dataRoot = firstString(args['data-root']) ?? undefined
|
|
154
205
|
const workspaceId = firstString(args['workspace-id']) ?? undefined
|
|
155
206
|
const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
|
|
156
207
|
if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
|
|
157
208
|
return {
|
|
158
|
-
loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
|
|
209
|
+
loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome, dataRoot, env }) : () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
|
|
210
|
+
...(personalHome === null ? {} : { locateProject: () => locatePersonalHome({ personalHome }) }),
|
|
159
211
|
...(dataRoot === undefined ? {} : { dataRoot }), ...(workspaceId === undefined ? {} : { workspaceId }), ...(intervalMs === undefined ? {} : { intervalMs }),
|
|
160
212
|
...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
|
|
161
213
|
startup: args.startup === true, adapter: firstString(args.adapter),
|
|
@@ -167,7 +219,7 @@ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.rea
|
|
|
167
219
|
if (invokedDirectly) {
|
|
168
220
|
const argv = process.argv.slice(2)
|
|
169
221
|
// As early as this entry can: its static imports have already loaded most of the modules, so a release that changes
|
|
170
|
-
// between this process's start and this reading is not noticed until the next change (docs/obsidian.md says so).
|
|
222
|
+
// between this process's start and this reading is not noticed until the next change (docs/guides/obsidian.md says so).
|
|
171
223
|
const releaseWatch = argv.includes('--startup') ? watchRelease(process.argv[1]) : null
|
|
172
224
|
let options = null
|
|
173
225
|
try {
|