@mnstry/atelier 0.2.0-alpha.14 → 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 +50 -0
- package/README.md +80 -47
- 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/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- 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} +13 -12
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +134 -47
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +7 -4
- 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} +23 -9
- 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 -5
- 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} +1 -1
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +6 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +5 -66
- 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} +27 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- 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} +6 -6
- 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 +2 -1
- package/package.json +21 -8
- 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 +5 -4
- package/src/boundary/policy.mjs +60 -12
- package/src/cli/run.mjs +24 -8
- 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 +28 -16
- 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/knowledge-graph.mjs +1 -1
- 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/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +7 -7
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/selection.mjs +17 -2
- package/src/project/config.mjs +1 -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 +1 -1
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +24 -28
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/publication/publisher.mjs +25 -4
- package/src/projection/project.mjs +18 -14
- 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 +99 -26
- package/src/runtime/obsidian/lifecycle.mjs +3 -3
- package/src/runtime/obsidian/login-item.mjs +2 -2
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/opening.mjs +2 -2
- package/src/runtime/obsidian/personal-selection.mjs +88 -26
- package/src/runtime/obsidian/pipeline.mjs +75 -7
- package/src/runtime/obsidian/private-lock.mjs +11 -2
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +15 -9
- package/src/runtime/obsidian/service.mjs +14 -0
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +2 -1
- package/src/upgrade/upgrade.mjs +23 -4
- 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 +2 -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/{capability-stewardship.md → guides/capability-stewardship.md} +0 -0
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +0 -0
- /package/docs/{learning-harnesses.md → guides/learning-harnesses.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/{semantic-operation.md → reference/semantic-operation.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
2
|
-
import { PersonalWorkspaceRefusal
|
|
3
|
+
import { PersonalWorkspaceRefusal } from '@mnstry/atelier/personal-workspace'
|
|
3
4
|
import { ensureLocalState } from '@mnstry/atelier/project'
|
|
4
5
|
import { assertPersonalSelection, loadBoundProject, loadBoundProjectOffThread, notePersonalSelectionHead, personalSelectionOf, pinPersonalSelection } from '../../projection/obsidian/personal-workspace.mjs'
|
|
5
6
|
import { refuse } from './errors.mjs'
|
|
7
|
+
import { readPersonalSelectionSnapshot } from '../../personal-workspace/selection.mjs'
|
|
8
|
+
import { assertOutsideRepositories, ensureWorkspaceIdentity, resolveDataRoot, workspaceStateRoot } from './machine-settings.mjs'
|
|
9
|
+
import { createSelectionContinuity } from './selection-continuity.mjs'
|
|
6
10
|
|
|
7
11
|
// A person's confirmed selection, bound to the maintenance runtime.
|
|
8
12
|
//
|
|
@@ -42,21 +46,19 @@ import { refuse } from './errors.mjs'
|
|
|
42
46
|
// already loaded, and a read the engine asks for when its guard stops a view
|
|
43
47
|
// because an observed file changed or could not be read (once for views
|
|
44
48
|
// stopped one after another). A
|
|
45
|
-
// history that ends earlier than the highest record noted refuses
|
|
46
|
-
// `selection-history-shortened` at a load and at a recheck, and
|
|
47
|
-
// loaded or used from it while it is shorter; the guard's read only notes, and
|
|
49
|
+
// history that ends earlier than the highest record noted requires a fresh confirmation or refuses
|
|
50
|
+
// `selection-history-shortened` at a load and at a recheck; the guard's read only notes, and
|
|
48
51
|
// its view says `mixed-read`. An engine whose project is pinned below the
|
|
49
52
|
// highest record noted loads it again at its next tick
|
|
50
53
|
// (personalSelectionBehind). A record added and removed between two of those
|
|
51
54
|
// reads was never read, and does not count.
|
|
52
55
|
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// does any start of it (a login, a restart after an upgrade or a failure).
|
|
56
|
+
// The highest number, accepted head and observed record identities are also kept in the workspace's private
|
|
57
|
+
// continuity state. A start of the service reads that evidence too. A shorter history remains refused until the
|
|
58
|
+
// removed records return or the person makes a fresh confirmation. New confirmations have a unique identity, so
|
|
59
|
+
// confirming the same generation against the same predecessor is distinct from restoring an old record's bytes.
|
|
58
60
|
//
|
|
59
|
-
// Who may see a vault
|
|
61
|
+
// Who may see a vault stays the machine's setting: the engine initializes an undecided personal binding as owner only.
|
|
60
62
|
//
|
|
61
63
|
// Where the workspace is does not wait for a load: a bound project's folder is
|
|
62
64
|
// its private home, so the home alone locates it (locatePersonalHome). A
|
|
@@ -78,30 +80,88 @@ export const SELECTION_PRIMITIVES = Object.freeze({
|
|
|
78
80
|
const SOURCE = Object.freeze({ source: 'personal-workspace' })
|
|
79
81
|
const LOCATED = Symbol('atelier.obsidian.located-personal-home')
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
const CONTINUITY = new Map()
|
|
84
|
+
|
|
85
|
+
// Exclusion roots only, shared by location and continuity. Composition remains
|
|
86
|
+
// the authority for the manifest and generation. Never wait on an irregular
|
|
87
|
+
// file while resolving the machine's private state.
|
|
88
|
+
function manifestExclusionRoots(personalHome) {
|
|
89
|
+
let manifest
|
|
90
|
+
let descriptor
|
|
91
|
+
try {
|
|
92
|
+
descriptor = fs.openSync(path.join(personalHome, 'atelier.personal.json'), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK ?? 0))
|
|
93
|
+
const stat = fs.fstatSync(descriptor)
|
|
94
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) throw new PersonalWorkspaceRefusal('malformed-input')
|
|
95
|
+
const bytes = fs.readFileSync(descriptor)
|
|
96
|
+
if (bytes.length > 1024 * 1024) throw new PersonalWorkspaceRefusal('malformed-input')
|
|
97
|
+
try { manifest = JSON.parse(bytes) } catch { throw new PersonalWorkspaceRefusal('malformed-input') }
|
|
98
|
+
if (!Array.isArray(manifest?.repos) || manifest.repos.some((repo) => typeof repo?.root !== 'string' || !path.isAbsolute(repo.root) || repo.root.includes('\0'))) throw new PersonalWorkspaceRefusal('malformed-input')
|
|
99
|
+
} catch (error) {
|
|
100
|
+
refuse(error instanceof PersonalWorkspaceRefusal ? error.code : 'personal-home-unavailable', 'the personal workspace manifest could not be read; no selection is loaded', SOURCE)
|
|
101
|
+
} finally { if (descriptor !== undefined) fs.closeSync(descriptor) }
|
|
102
|
+
return manifest.repos.map((repo) => repo.root)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function continuityFor({ personalHome, dataRoot, env = process.env, platform = process.platform }) {
|
|
106
|
+
const project = locatePersonalHome({ personalHome })
|
|
107
|
+
const repositoryRoots = [personalHome, ...project.repos.map((repo) => repo.path)]
|
|
108
|
+
const rootOf = (named) => workspaceStateRoot(resolveDataRoot({ dataRoot, pointer: named, project, env, platform }), named.workspaceId)
|
|
109
|
+
// The location is proven outside every declared root before a first use writes its pointer.
|
|
110
|
+
const pointer = ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }),
|
|
111
|
+
validate: (named) => assertOutsideRepositories({ managedRoot: rootOf(named), repositoryRoots }) })
|
|
112
|
+
const root = rootOf(pointer)
|
|
113
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
114
|
+
const workspaceRoot = fs.realpathSync(root)
|
|
115
|
+
const key = `${personalHome}\0${workspaceRoot}`
|
|
116
|
+
// A retained store writes later, after the manifest may have changed: each write proves the location again from
|
|
117
|
+
// the manifest as it reads now, and a refusal there writes nothing.
|
|
118
|
+
if (!CONTINUITY.has(key)) CONTINUITY.set(key, createSelectionContinuity({ workspaceRoot, workspaceId: pointer.workspaceId, personalHome,
|
|
119
|
+
validateLocation: () => assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots: personalExclusionRootsNow(personalHome) }) }))
|
|
120
|
+
return CONTINUITY.get(key)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The roots a personal home's private state must stay outside, read from its manifest now. Read-only.
|
|
124
|
+
export function personalExclusionRootsNow(personalHome) {
|
|
125
|
+
return [personalHome, ...manifestExclusionRoots(personalHome)]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function confirmedHead(options, rules) {
|
|
129
|
+
const { personalHome } = options
|
|
130
|
+
locatePersonalHome({ personalHome })
|
|
83
131
|
let head
|
|
84
|
-
try { head =
|
|
132
|
+
try { head = readPersonalSelectionSnapshot({ personalHome }) } catch (error) {
|
|
85
133
|
if (!(error instanceof PersonalWorkspaceRefusal)) throw error
|
|
86
134
|
refuse(error.code, 'the selection history of the personal workspace could not be read; nothing is loaded from it', SOURCE)
|
|
87
135
|
}
|
|
88
|
-
|
|
136
|
+
const continuity = continuityFor(options)
|
|
137
|
+
const note = (current) => { if (rules.refusesShortened) continuity.note(current) }
|
|
138
|
+
notePersonalSelectionHead(personalHome, head, { ...rules, note })
|
|
89
139
|
if (head.selected === null) refuse('nothing-selected', 'no generation of the personal workspace is confirmed; nothing is loaded until one is', SOURCE)
|
|
90
|
-
return head
|
|
140
|
+
return { head, note }
|
|
91
141
|
}
|
|
92
142
|
|
|
93
|
-
function pinned(project, head, rules) {
|
|
94
|
-
pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened })
|
|
143
|
+
function pinned(project, { head, note }, rules) {
|
|
144
|
+
pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened, note })
|
|
95
145
|
if (rules.readsHeadAgain) assertPersonalSelection(project)
|
|
96
146
|
return project
|
|
97
147
|
}
|
|
98
148
|
|
|
99
149
|
// What the workspace of a bound personal home is resolved from, without loading anything: a project that holds the home
|
|
100
|
-
// as its folder
|
|
101
|
-
// pointer's or the caller's, and a home is
|
|
150
|
+
// as its folder, plus the manifest's repository roots for exclusion only. A locator never admits a generation or
|
|
151
|
+
// notes a history head. Its pointer is under the home, the data root is the pointer's or the caller's, and a home is
|
|
152
|
+
// outside every Git worktree. Synchronous; nothing is composed. A missing home still locates no prepared workspace.
|
|
102
153
|
export function locatePersonalHome({ personalHome } = {}) {
|
|
103
154
|
if (typeof personalHome !== 'string' || !path.isAbsolute(personalHome) || path.resolve(personalHome) !== personalHome) refuse('path-not-absolute', 'a personal home is named by its absolute path', SOURCE)
|
|
104
|
-
|
|
155
|
+
let stat = null
|
|
156
|
+
try { stat = fs.lstatSync(personalHome) } catch (error) { if (error.code !== 'ENOENT') refuse('personal-home-unavailable', 'the personal home could not be inspected', SOURCE) }
|
|
157
|
+
if (stat !== null) {
|
|
158
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(personalHome) !== personalHome) refuse('root-symlinked', 'a personal home is its private directory, not a link', SOURCE)
|
|
159
|
+
if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) refuse('not-private-location', 'a personal home is private to its owner', SOURCE)
|
|
160
|
+
}
|
|
161
|
+
// Include all declared roots, even a withdrawn enrollment: locating state
|
|
162
|
+
// must not write into a repository while composition is unavailable.
|
|
163
|
+
const roots = stat === null ? [] : manifestExclusionRoots(personalHome)
|
|
164
|
+
const located = { configDir: personalHome, repos: roots.map((root) => ({ path: root, external: false })), [LOCATED]: personalHome }
|
|
105
165
|
located.localState = ensureLocalState(located, { write: false, env: { PATH: process.env.PATH } })
|
|
106
166
|
return located
|
|
107
167
|
}
|
|
@@ -113,13 +173,15 @@ export { personalSelectionOf }
|
|
|
113
173
|
|
|
114
174
|
// The project of the confirmed selection, for a loader that may block (a command run once). `bind` and `rules` are
|
|
115
175
|
// test seams.
|
|
116
|
-
export function loadSelectedProject(
|
|
117
|
-
const
|
|
118
|
-
|
|
176
|
+
export function loadSelectedProject(options = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
|
|
177
|
+
const { personalHome } = options
|
|
178
|
+
const selected = confirmedHead(options, rules)
|
|
179
|
+
return pinned(loadBoundProject({ personalHome, generationId: selected.head.selected }, { bind }), selected, rules)
|
|
119
180
|
}
|
|
120
181
|
|
|
121
182
|
// The same, for a loader on a service's event loop: the composition runs in a worker, and the engine awaits it.
|
|
122
|
-
export async function loadSelectedProjectOffThread(
|
|
123
|
-
const
|
|
124
|
-
|
|
183
|
+
export async function loadSelectedProjectOffThread(options = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
|
|
184
|
+
const { personalHome } = options
|
|
185
|
+
const selected = confirmedHead(options, rules)
|
|
186
|
+
return pinned(await loadBoundProjectOffThread({ personalHome, generationId: selected.head.selected }, { bind }), selected, rules)
|
|
125
187
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCooperativeBudget, prepareViewCooperatively } from '../../projection/obsidian/materialize/prepare-view.mjs'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
+
import fs from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { buildCanonicalGraph, buildCanonicalGraphSteps, createGraphFileCache } from '../../graph/graph.mjs'
|
|
5
6
|
import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
|
|
@@ -130,8 +131,14 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
|
|
|
130
131
|
// on this thread.
|
|
131
132
|
export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
|
|
132
133
|
const observedDigest = observedDigestOf(cache, index)
|
|
133
|
-
|
|
134
|
-
|
|
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 })
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
// The same graph, built with the event loop given a turn between the Markdown sources of the census, under the
|
|
@@ -143,7 +150,7 @@ export async function buildGraphCooperatively({ project, eligibility, cache = nu
|
|
|
143
150
|
const observedDigest = observedDigestOf(cache, index)
|
|
144
151
|
// The personal route answers for a bound project, and refuses a project that carries the reserved key unbound.
|
|
145
152
|
const personal = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
|
|
146
|
-
if (personal !== null) return eligibleGraph(personal, eligibility)
|
|
153
|
+
if (personal !== null) return eligibleGraph(personal, eligibility, { project, index })
|
|
147
154
|
const budget = createCooperativeBudget(scheduling)
|
|
148
155
|
budget.check()
|
|
149
156
|
const steps = buildCanonicalGraphSteps(project, { fileCache: cache, observedDigest })
|
|
@@ -156,16 +163,66 @@ export async function buildGraphCooperatively({ project, eligibility, cache = nu
|
|
|
156
163
|
}
|
|
157
164
|
} catch (error) {
|
|
158
165
|
steps.return?.()
|
|
166
|
+
refuseObservedDirectory(error, { project, index })
|
|
159
167
|
throw error
|
|
160
168
|
}
|
|
161
169
|
budget.finish('graph-assembled')
|
|
162
|
-
return eligibleGraph(canonical, eligibility)
|
|
170
|
+
return eligibleGraph(canonical, eligibility, { project, index })
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
const observedDigestOf = (cache, index) => (cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null)
|
|
166
174
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
226
|
return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
|
|
170
227
|
}
|
|
171
228
|
|
|
@@ -190,11 +247,13 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
|
|
|
190
247
|
return path.join(root, ...relative.split('/'))
|
|
191
248
|
}
|
|
192
249
|
const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
|
|
250
|
+
const observedReads = new Set()
|
|
193
251
|
// Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
|
|
194
252
|
const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
|
|
195
253
|
for (const item of pinned) {
|
|
196
254
|
if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
|
|
197
255
|
const observed = index.get(sourceKey(item.repo, item.path))
|
|
256
|
+
if (typeof observed?.digest === 'string') observedReads.add(sourceKey(item.repo, item.path))
|
|
198
257
|
const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
|
|
199
258
|
if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
|
|
200
259
|
byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
|
|
@@ -217,7 +276,16 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
|
|
|
217
276
|
repositories,
|
|
218
277
|
},
|
|
219
278
|
graph,
|
|
220
|
-
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
|
+
},
|
|
221
289
|
}
|
|
222
290
|
}
|
|
223
291
|
|
|
@@ -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')
|
|
@@ -128,7 +128,11 @@ export async function inspectPrivateGenerationLock({ directory, workspaceId, pro
|
|
|
128
128
|
|
|
129
129
|
// { acquired: true, generation, release() } or { acquired: false, reason, holder }.
|
|
130
130
|
// Never waits and never throws for a lock that is merely held.
|
|
131
|
-
|
|
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()
|
|
132
136
|
const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
|
|
133
137
|
const owed = UNWITHDRAWN.get(contained)
|
|
134
138
|
if (owed !== undefined) {
|
|
@@ -140,6 +144,7 @@ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, w
|
|
|
140
144
|
const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
|
|
141
145
|
if (!status.available) return { acquired: false, reason: status.reason, holder }
|
|
142
146
|
if (status.generation >= MAX_GENERATION) return { acquired: false, reason: 'lock-generations-exhausted', holder }
|
|
147
|
+
validateLocation()
|
|
143
148
|
const generation = status.generation + 1
|
|
144
149
|
const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
|
|
145
150
|
const body = JSON.stringify(ticket)
|
|
@@ -167,6 +172,10 @@ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, w
|
|
|
167
172
|
try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
|
|
168
173
|
return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
|
|
169
174
|
}
|
|
175
|
+
try { validateLocation() } catch (error) {
|
|
176
|
+
withdraw()
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
170
179
|
for (const name of after.names) {
|
|
171
180
|
if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
|
|
172
181
|
}
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
|
|
6
6
|
import { workspaceStateRoot } from './machine-settings.mjs'
|
|
7
7
|
import { loadSelectedProjectOffThread, locatePersonalHome } from './personal-selection.mjs'
|
|
8
|
-
import { RELEASE_CHANGED, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
|
|
8
|
+
import { RELEASE_CHANGED, isLocationRefusalCode, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
|
|
9
9
|
|
|
10
10
|
// The process of the maintenance service. `start` runs it as a child; a unit
|
|
11
11
|
// installed at the operating-system level runs it with `--startup`.
|
|
@@ -108,6 +108,7 @@ function firstAnswerOf(loadProject) {
|
|
|
108
108
|
return {
|
|
109
109
|
// The project, when the loader answered one at once; null when it threw or answered a promise.
|
|
110
110
|
project: () => (first.threw || first.promised ? null : first.project),
|
|
111
|
+
threw: () => first.threw === true,
|
|
111
112
|
loadProject() {
|
|
112
113
|
if (taken) return loadProject()
|
|
113
114
|
taken = true
|
|
@@ -120,12 +121,15 @@ function firstAnswerOf(loadProject) {
|
|
|
120
121
|
// The workspace of a service a login item started, when it can be found: where it keeps its log and records how its
|
|
121
122
|
// start ended. When the project no longer leads to one (it moved, its pointer is gone, its loader answered no project
|
|
122
123
|
// at once), the workspace the unit names by its data root and identity, if that exists; it is never created here. Null
|
|
123
|
-
// otherwise
|
|
124
|
-
|
|
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
|
|
125
129
|
try {
|
|
126
130
|
const workspace = project === null ? null : resolveServiceWorkspace({ project, dataRoot, env, platform })
|
|
127
131
|
if (workspace?.workspaceRoot) return workspace
|
|
128
|
-
} catch {
|
|
132
|
+
} catch { return null }
|
|
129
133
|
if (typeof dataRoot !== 'string' || !path.isAbsolute(dataRoot) || typeof workspaceId !== 'string') return null
|
|
130
134
|
try {
|
|
131
135
|
const root = workspaceStateRoot(path.resolve(dataRoot), workspaceId)
|
|
@@ -162,7 +166,7 @@ export async function runServiceProcess(options) {
|
|
|
162
166
|
const located = typeof options.locateProject === 'function'
|
|
163
167
|
const asked = located ? options.locateProject : options.loadProject
|
|
164
168
|
const first = startup && typeof asked === 'function' ? firstAnswerOf(asked) : null
|
|
165
|
-
const workspace = startup ? startupWorkspace({ ...options, project: first === null ? null : first.project() }) : null
|
|
169
|
+
const workspace = startup ? startupWorkspace({ ...options, located, locatorRefused: first?.threw() === true, project: first === null ? null : first.project() }) : null
|
|
166
170
|
const log = workspace === null ? printed : workspaceLog(workspace.workspaceRoot)
|
|
167
171
|
try {
|
|
168
172
|
const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options, ...(first === null ? {} : { [located ? 'locateProject' : 'loadProject']: first.loadProject }) })
|
|
@@ -175,8 +179,10 @@ export async function runServiceProcess(options) {
|
|
|
175
179
|
const at = new Date().toISOString()
|
|
176
180
|
// What was thrown is read without trusting it: a value with no prototype, or a getter that throws, is still logged.
|
|
177
181
|
const code = readOr(() => (typeof error?.code === 'string' ? error.code : null), null)
|
|
178
|
-
|
|
179
|
-
|
|
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 }
|
|
180
186
|
process.exitCode = code !== null ? EXIT_REFUSED : 1
|
|
181
187
|
}
|
|
182
188
|
}
|
|
@@ -200,7 +206,7 @@ export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
|
|
|
200
206
|
const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
|
|
201
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' })
|
|
202
208
|
return {
|
|
203
|
-
loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome }) : () => 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 }),
|
|
204
210
|
...(personalHome === null ? {} : { locateProject: () => locatePersonalHome({ personalHome }) }),
|
|
205
211
|
...(dataRoot === undefined ? {} : { dataRoot }), ...(workspaceId === undefined ? {} : { workspaceId }), ...(intervalMs === undefined ? {} : { intervalMs }),
|
|
206
212
|
...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
|
|
@@ -213,7 +219,7 @@ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.rea
|
|
|
213
219
|
if (invokedDirectly) {
|
|
214
220
|
const argv = process.argv.slice(2)
|
|
215
221
|
// As early as this entry can: its static imports have already loaded most of the modules, so a release that changes
|
|
216
|
-
// 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).
|
|
217
223
|
const releaseWatch = argv.includes('--startup') ? watchRelease(process.argv[1]) : null
|
|
218
224
|
let options = null
|
|
219
225
|
try {
|
|
@@ -76,6 +76,15 @@ export function serviceWorkspaceInputs(project) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// Where a workspace keeps its private state, without creating an identity.
|
|
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
|
+
|
|
79
88
|
export function resolveServiceWorkspace({ project: whole, dataRoot, env = process.env, platform = process.platform, create = false }) {
|
|
80
89
|
const project = serviceWorkspaceInputs(whole)
|
|
81
90
|
const pointer = readLocalPointer(project)
|
|
@@ -87,6 +96,8 @@ export function resolveServiceWorkspace({ project: whole, dataRoot, env = proces
|
|
|
87
96
|
return { workspaceId: pointer.workspaceId, workspaceRoot: fs.realpathSync(requested) }
|
|
88
97
|
}
|
|
89
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 ?? ''))
|
|
90
101
|
const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal
|
|
91
102
|
const errorCode = (error) => (typeof error?.code === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(error.code) ? error.code : 'untyped-error')
|
|
92
103
|
const errorName = (error) => (typeof error?.name === 'string' ? error.name.slice(0, 64) : 'Error')
|
|
@@ -237,6 +248,8 @@ export async function runMaintenanceService(options = {}) {
|
|
|
237
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 } : {}) })
|
|
238
249
|
}
|
|
239
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
|
|
240
253
|
const previous = readLastServiceError({ workspaceRoot, workspaceId })
|
|
241
254
|
if (previous && previous.resolvedAt === null) writeLastServiceError({ workspaceRoot, workspaceId, document: { ...previous, consecutiveFailures: 0, resolvedAt: at } })
|
|
242
255
|
return
|
|
@@ -245,6 +258,7 @@ export async function runMaintenanceService(options = {}) {
|
|
|
245
258
|
refusedLastTick = new Map()
|
|
246
259
|
// The message may carry a path or a title: it goes to the private log and nowhere else.
|
|
247
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
|
|
248
262
|
let previous = null
|
|
249
263
|
try { previous = readLastServiceError({ workspaceRoot, workspaceId }) } catch { previous = null }
|
|
250
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 } })
|
|
@@ -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
|
|
@@ -167,7 +167,8 @@ export const DEFAULT_ROW_ENDS_AFTER_MS = 60 * 1000
|
|
|
167
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
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
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
|
|
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
|
|
171
172
|
// load that answered in time and kept the project writes nothing unless it ends a row of failures: that is every load
|
|
172
173
|
// of a healthy service. A line holds the event, milliseconds, the outcome, flags and counts; never a path, a view or a
|
|
173
174
|
// message.
|