@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
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readLastStartup, readServiceRecord, readServiceSettings, releaseOfEntry, removeServiceRecord,
|
|
16
16
|
serviceNameFor, servicePaths, writeServiceSettings,
|
|
17
17
|
} from './service-record.mjs'
|
|
18
|
-
import {
|
|
18
|
+
import { personalHomeOf } from './personal-selection.mjs'
|
|
19
|
+
import { resolveServiceWorkspace, serviceLocationCheck } from './service.mjs'
|
|
19
20
|
|
|
20
21
|
// start / status / stop of the maintenance service of one workspace, as
|
|
21
|
-
// docs/local-services.md describes them.
|
|
22
|
+
// docs/reference/local-services.md describes them.
|
|
22
23
|
//
|
|
23
24
|
// Ownership is proven, never assumed. A running service is ours only when the
|
|
24
25
|
// owner-only record and the health answer agree on service name, workspace,
|
|
@@ -61,10 +62,12 @@ export const LIFECYCLE_PRIMITIVES = Object.freeze({
|
|
|
61
62
|
provesOurProcess: (record) => processRunsRecordedExecutable(record),
|
|
62
63
|
})
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
// `locateProject`, when given, answers what the workspace is resolved from without loading the project (a bound
|
|
66
|
+
// personal home, locatePersonalHome): its service is then found, started and stopped whatever its selection holds.
|
|
67
|
+
function context({ loadProject, locateProject = null, dataRoot, env, platform, create = false, randomBytes }) {
|
|
65
68
|
if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
|
|
66
|
-
const project = loadProject()
|
|
67
|
-
if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
|
|
69
|
+
const project = locateProject === null ? loadProject() : locateProject()
|
|
70
|
+
if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
|
|
68
71
|
const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
|
|
69
72
|
return { project, workspace }
|
|
70
73
|
}
|
|
@@ -90,8 +93,8 @@ async function evaluate({ workspaceRoot, workspaceId }, { probeTimeoutMs = DEFAU
|
|
|
90
93
|
|
|
91
94
|
const shown = ({ record, ...status }) => ({ ...status, record: publicRecord(record) })
|
|
92
95
|
|
|
93
|
-
export async function serviceStatus({ loadProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
94
|
-
const { workspace } = context({ loadProject, dataRoot, env, platform })
|
|
96
|
+
export async function serviceStatus({ loadProject, locateProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
97
|
+
const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
|
|
95
98
|
if (!workspace?.workspaceRoot) return { state: 'stopped', reason: 'workspace-not-prepared', workspaceId: workspace?.workspaceId ?? null, record: null, address: null }
|
|
96
99
|
return shown(await evaluate(workspace, { probeTimeoutMs, alive, rules }))
|
|
97
100
|
}
|
|
@@ -177,11 +180,11 @@ async function startThroughLoginItem({ workspace, loginItem, entryPath, deadline
|
|
|
177
180
|
// proven runtime is answered as already running.
|
|
178
181
|
export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
179
182
|
const {
|
|
180
|
-
loadProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
|
|
183
|
+
loadProject, locateProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
|
|
181
184
|
startTimeoutMs = DEFAULT_START_TIMEOUT_MS, probeTimeoutMs, clock = () => new Date(), env = process.env, platform = process.platform,
|
|
182
185
|
spawn = childProcess.spawn, execPath = process.execPath, alive = isProcessAlive, randomBytes = cryptoRandomBytes, loginItem = null, replaceOutdated = false,
|
|
183
186
|
} = options
|
|
184
|
-
const { project, workspace } = context({ loadProject, dataRoot, env, platform, create: true })
|
|
187
|
+
const { project, workspace } = context({ loadProject, locateProject, dataRoot, env, platform, create: true })
|
|
185
188
|
const { workspaceRoot, workspaceId } = workspace
|
|
186
189
|
const deadline = Date.now() + startTimeoutMs
|
|
187
190
|
|
|
@@ -208,7 +211,7 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
|
208
211
|
const standing = replaceOutdated ? runtimeRelease(before, entryPath) : null
|
|
209
212
|
if (standing === 'later') return { ...shown(before), started: false, alreadyRunning: true, release: 'later' }
|
|
210
213
|
if (standing !== 'outdated') return { ...shown(before), started: false, alreadyRunning: true }
|
|
211
|
-
const stopped = await stopService({ loadProject, dataRoot, env, platform, probeTimeoutMs, alive }, rules)
|
|
214
|
+
const stopped = await stopService({ loadProject, locateProject, dataRoot, env, platform, probeTimeoutMs, alive }, rules)
|
|
212
215
|
if (!stopped.stopped) return { ...shown(before), started: false, alreadyRunning: true, release: 'outdated', reason: stopped.reason ?? 'the-runtime-was-not-stopped' }
|
|
213
216
|
replaced = 'outdated'
|
|
214
217
|
before = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
@@ -241,7 +244,9 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
|
241
244
|
const executable = executableIdentity(entryPath)
|
|
242
245
|
const log = openServiceLog(workspaceRoot)
|
|
243
246
|
try {
|
|
244
|
-
|
|
247
|
+
// A bound personal home is named instead of a project: the service follows the selection confirmed there.
|
|
248
|
+
const home = personalHomeOf(project)
|
|
249
|
+
const args = [executable.path, home === null ? `--project=${project.configPath}` : `--personal-home=${home}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
|
|
245
250
|
// No shell. Detached only when the service is meant to outlive the command that starts it. It runs in the root
|
|
246
251
|
// directory, never in the one the command was started in, which may be a vault or a folder somebody removes.
|
|
247
252
|
child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env, cwd: path.parse(executable.path).root })
|
|
@@ -277,8 +282,8 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
280
|
-
const { loadProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
|
|
281
|
-
const { workspace } = context({ loadProject, dataRoot, env, platform })
|
|
285
|
+
const { loadProject, locateProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
|
|
286
|
+
const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
|
|
282
287
|
if (!workspace?.workspaceRoot) return { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' }
|
|
283
288
|
const { workspaceRoot, workspaceId } = workspace
|
|
284
289
|
const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
@@ -308,8 +313,8 @@ export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
|
308
313
|
// One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
|
|
309
314
|
// with that status and no request is made.
|
|
310
315
|
async function askProvenRuntime(options, rules, { method, operation, timeoutMs, body = {} }) {
|
|
311
|
-
const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
|
|
312
|
-
const { workspace } = context({ loadProject, dataRoot, env, platform })
|
|
316
|
+
const { loadProject, locateProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
|
|
317
|
+
const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
|
|
313
318
|
if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
|
|
314
319
|
const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
315
320
|
if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
|
|
@@ -9,7 +9,8 @@ import { ensureServiceSettings } from './lifecycle.mjs'
|
|
|
9
9
|
import { ensureWorkspaceIdentity, readLocalPointer, resolveDataRoot } from './machine-settings.mjs'
|
|
10
10
|
import { SERVICE_MANAGER_KINDS } from './service-managers.mjs'
|
|
11
11
|
import { readLastStartup, readServiceSettings, serviceNameFor, servicePaths, writeServiceSettings } from './service-record.mjs'
|
|
12
|
-
import {
|
|
12
|
+
import { personalHomeOf } from './personal-selection.mjs'
|
|
13
|
+
import { resolveServiceWorkspace, serviceLocationCheck } from './service.mjs'
|
|
13
14
|
import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from './startup-adapters.mjs'
|
|
14
15
|
|
|
15
16
|
// A login item: the operating system starts this workspace's maintenance
|
|
@@ -164,7 +165,9 @@ export const loginItemDataRoot = ({ project, dataRoot, env = process.env, platfo
|
|
|
164
165
|
export function planLoginItem({ platform, project, workspaceRoot, workspaceId, dataRoot, label, entryPath, entryArgs = [], nodePath, searchPath = null }) {
|
|
165
166
|
if (!isAbsolutePlain(dataRoot)) throw new TypeError('a login item names the absolute data root of its workspace')
|
|
166
167
|
if (typeof workspaceId !== 'string' || workspaceId === '') throw new TypeError('a login item names its workspace')
|
|
167
|
-
|
|
168
|
+
// A bound personal home is named instead of a project, as `start` names it (lifecycle.mjs).
|
|
169
|
+
const home = personalHomeOf(project)
|
|
170
|
+
const args = [home === null ? `--project=${project.configPath}` : `--personal-home=${home}`, `--data-root=${dataRoot}`, `--workspace-id=${workspaceId}`, ...entryArgs]
|
|
168
171
|
const unit = buildStartupAdapter({ platform, label, nodePath, entryPath, args, logPath: servicePaths(workspaceRoot).loginItemLog, searchPath })
|
|
169
172
|
return { ...unit, label, digest: sha256(unit.text), program: { node: nodePath, entry: entryPath }, searchPath }
|
|
170
173
|
}
|
|
@@ -195,7 +198,7 @@ export async function installLoginItem(options = {}) {
|
|
|
195
198
|
const { loadProject, dataRoot, env = process.env, platform = process.platform, manager, consent, ownEntry, entryArgs = [], nodePath = realNodePath(), pathValue = env.PATH, clock = () => new Date(), temporary = temporaryRoots(), beforeInstall = null } = options
|
|
196
199
|
checkManager(manager, platform)
|
|
197
200
|
const project = loadProject()
|
|
198
|
-
ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }) })
|
|
201
|
+
ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
|
|
199
202
|
const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create: true })
|
|
200
203
|
const { entryPath, source } = resolveLoginItemEntry({ project, ownEntry, temporary })
|
|
201
204
|
const recorded = readLoginItemRecord(workspace)
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { LOCAL_STATE_DIR, localStateRoot } from '../../project/config.mjs'
|
|
6
|
+
import { withPrivateLock } from '../../project/durable-state.mjs'
|
|
6
7
|
import { checkManagedRoots } from '../../project/file-class.mjs'
|
|
7
8
|
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
8
9
|
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
|
|
@@ -146,12 +147,20 @@ export function writeLocalPointer(project, pointer) {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// Reads the persisted workspace identity, or issues a random one and persists
|
|
149
|
-
// it. `randomBytes` is injectable so tests are deterministic.
|
|
150
|
-
|
|
150
|
+
// it. `randomBytes` is injectable so tests are deterministic. `validate`, when
|
|
151
|
+
// given, checks the location an identity names: a new identity is checked
|
|
152
|
+
// before its pointer is written, so a refused location leaves no pointer, and
|
|
153
|
+
// the identity actually persisted is checked again before it is returned.
|
|
154
|
+
export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes, validate = null } = {}) {
|
|
151
155
|
const existing = readLocalPointer(project)
|
|
152
|
-
if (existing) return existing
|
|
153
|
-
const pointer = { schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) }
|
|
154
|
-
|
|
156
|
+
if (existing) { validate?.(existing); return existing }
|
|
157
|
+
const pointer = validatePointer({ schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) })
|
|
158
|
+
validate?.(pointer)
|
|
159
|
+
writeLocalPointer(project, pointer)
|
|
160
|
+
const persisted = readLocalPointer(project)
|
|
161
|
+
if (persisted === null) refuse('invalid-local-pointer', 'the Obsidian pointer was not kept')
|
|
162
|
+
validate?.(persisted)
|
|
163
|
+
return persisted
|
|
155
164
|
}
|
|
156
165
|
|
|
157
166
|
// ---------------------------------------------------------------------------
|
|
@@ -260,6 +269,18 @@ export function defaultMachineSettings({ workspaceId, updatedAt }) {
|
|
|
260
269
|
}
|
|
261
270
|
}
|
|
262
271
|
|
|
272
|
+
// The already adopted owner-only audience for a personal binding, recorded in the same machine setting an ordinary
|
|
273
|
+
// workspace uses. Existing explicit decisions keep their exact list and unclassified choice; a clear stays empty.
|
|
274
|
+
export function ownerOnlyPersonalSettings(settings, { decidedAt }) {
|
|
275
|
+
const decision = settings.decisions.audience
|
|
276
|
+
if (decision === null && settings.audienceAllow.length === 0) {
|
|
277
|
+
return withDecision({ ...settings, audienceAllow: [...ONLY_YOU_AUDIENCES], updatedAt: decidedAt }, 'audience',
|
|
278
|
+
{ choice: 'only-you', unclassified: 'shown' }, { decidedAt, via: 'defaults' })
|
|
279
|
+
}
|
|
280
|
+
if (decision?.choice === 'only-you' || (decision?.choice === 'custom' && settings.audienceAllow.length === 0)) return settings
|
|
281
|
+
refuse('personal-binding-owner-only', 'a personal workspace vault is owner only; select `audience set me` or clear its audience before publishing')
|
|
282
|
+
}
|
|
283
|
+
|
|
263
284
|
// The settings with one decision recorded, validated; nothing is written. `decision` holds the decision's own members
|
|
264
285
|
// (`{ choice, unclassified }`, `{ parent }`, `{ choice }`); the stamp says when, by whom (an identifier, or null when not
|
|
265
286
|
// known) and how.
|
|
@@ -278,15 +299,38 @@ export function readMachineSettings({ workspaceRoot, workspaceId }) {
|
|
|
278
299
|
return document === null ? null : normalizeMachineSettings(document, workspaceId)
|
|
279
300
|
}
|
|
280
301
|
|
|
281
|
-
|
|
282
|
-
export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
|
|
283
|
-
const document = normalizeMachineSettings(settings, workspaceId)
|
|
302
|
+
function withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, operation) {
|
|
284
303
|
assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
|
|
285
304
|
fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
|
|
305
|
+
return withPrivateLock(path.join(settingsDirectory(workspaceRoot), 'machine-settings.lock'), operation)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function writeMachineSettingsUnlocked({ workspaceRoot, document }) {
|
|
286
309
|
atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'machine.json'), canonicalJson(document))
|
|
287
310
|
return document
|
|
288
311
|
}
|
|
289
312
|
|
|
313
|
+
// Writes v2, whatever version it is handed, and returns what it wrote. Commands and initialization share this lock.
|
|
314
|
+
export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
|
|
315
|
+
const document = normalizeMachineSettings(settings, workspaceId)
|
|
316
|
+
return withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, () => writeMachineSettingsUnlocked({ workspaceRoot, document }))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Read-only when already decided. If initialization is needed, reread under the write lock so a committed clear wins.
|
|
320
|
+
export function initializeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, updatedAt, personal = false }) {
|
|
321
|
+
const initialized = (current) => {
|
|
322
|
+
const initial = current ?? defaultMachineSettings({ workspaceId, updatedAt })
|
|
323
|
+
return personal ? ownerOnlyPersonalSettings(initial, { decidedAt: updatedAt }) : initial
|
|
324
|
+
}
|
|
325
|
+
const existing = readMachineSettings({ workspaceRoot, workspaceId })
|
|
326
|
+
if (initialized(existing) === existing) return existing
|
|
327
|
+
return withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, () => {
|
|
328
|
+
const current = readMachineSettings({ workspaceRoot, workspaceId })
|
|
329
|
+
const document = normalizeMachineSettings(initialized(current), workspaceId)
|
|
330
|
+
return document === current ? current : writeMachineSettingsUnlocked({ workspaceRoot, document })
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
290
334
|
// ---------------------------------------------------------------------------
|
|
291
335
|
// Installed apply policy
|
|
292
336
|
// ---------------------------------------------------------------------------
|
|
@@ -123,24 +123,52 @@ export function readFileFacts(file) {
|
|
|
123
123
|
|
|
124
124
|
const sameHint = (left, right) => left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.ino === right.ino
|
|
125
125
|
|
|
126
|
+
// Whether a file still has the stat hint it was indexed with: what a reconcile that is not full decides by before it
|
|
127
|
+
// would hash the file again. `entry` is the file's entry in the index. A file never hashed, or gone, has none.
|
|
128
|
+
export function hasIndexedHint(entry, absolute, lstat = fs.lstatSync) {
|
|
129
|
+
if (!entry || entry.digest === null || !entry.hint) return false
|
|
130
|
+
const hint = statHint(absolute, lstat)
|
|
131
|
+
return hint !== null && sameHint(entry.hint, hint)
|
|
132
|
+
}
|
|
133
|
+
|
|
126
134
|
// Reconciles `files` against `index` (a Map owned by the caller) and returns
|
|
127
135
|
// the changes, decided by digest alone. `full` hashes everything; otherwise a
|
|
128
136
|
// file is hashed when it is new, when its stat hint differs or when a watcher
|
|
129
137
|
// named it. Entries under `prefix` that are no longer listed are removals.
|
|
130
|
-
export function reconcile(
|
|
138
|
+
export function reconcile(input) {
|
|
139
|
+
const steps = reconcileSteps(input)
|
|
140
|
+
for (;;) { const step = steps.next(); if (step.done) return step.value }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The same reconciliation with the event loop given a turn between files, under the caller's cooperative budget
|
|
144
|
+
// (createCooperativeBudget): for a walk long enough to hold a service's listener, such as the read-back of a large
|
|
145
|
+
// view. Each file is still looked at once, in the listed order, and the index holds what was seen when each was read;
|
|
146
|
+
// a file that changes after its turn is the next look's to see, as one that changes after an uninterrupted walk is.
|
|
147
|
+
// The caller must not let another reconciliation of the same index run before this one has answered.
|
|
148
|
+
export async function reconcileCooperatively(input, budget) {
|
|
149
|
+
const steps = reconcileSteps(input)
|
|
150
|
+
for (;;) {
|
|
151
|
+
const step = steps.next()
|
|
152
|
+
if (step.done) return step.value
|
|
153
|
+
await budget.checkpoint('reconcile-file')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// One step a listed file; the removals and the answer after the last.
|
|
158
|
+
function* reconcileSteps({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
|
|
131
159
|
const changes = []
|
|
132
160
|
const present = new Set()
|
|
133
161
|
let hashed = 0
|
|
134
|
-
|
|
162
|
+
const look = (file) => {
|
|
135
163
|
present.add(file.key)
|
|
136
164
|
const previous = index.get(file.key)
|
|
137
165
|
const hint = statHint(file.absolute, lstat)
|
|
138
166
|
if (hint === null) {
|
|
139
167
|
if (previous && previous.digest !== null) changes.push({ key: file.key, changeClass: previous.changeClass, kind: 'removed' })
|
|
140
168
|
index.set(file.key, { changeClass: file.changeClass, hint: null, digest: null, byteLength: null })
|
|
141
|
-
|
|
169
|
+
return
|
|
142
170
|
}
|
|
143
|
-
if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key))
|
|
171
|
+
if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) return
|
|
144
172
|
const facts = readFileFacts(file.absolute)
|
|
145
173
|
const digest = facts?.digest ?? null
|
|
146
174
|
hashed += 1
|
|
@@ -149,6 +177,10 @@ export function reconcile({ index, files, prefix, full, hinted = () => false, ls
|
|
|
149
177
|
}
|
|
150
178
|
index.set(file.key, { changeClass: file.changeClass, hint: digest === null ? null : hint, digest, byteLength: facts?.byteLength ?? null })
|
|
151
179
|
}
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
look(file)
|
|
182
|
+
yield
|
|
183
|
+
}
|
|
152
184
|
for (const [key, previous] of index) {
|
|
153
185
|
if (!key.startsWith(prefix) || present.has(key)) continue
|
|
154
186
|
if (previous.digest !== null) changes.push({ key, changeClass: previous.changeClass, kind: 'removed' })
|
|
@@ -134,6 +134,7 @@ export const REASON_NEXT = Object.freeze({
|
|
|
134
134
|
// publisher or editor is involved.
|
|
135
135
|
'plugin-file-changed': 'a file of Atelier\'s plugin in this vault (under .obsidian/plugins) changed while the view was published, by another program or by you, so the publication did not finish; it is retried automatically, and if this keeps happening, stop what keeps writing there (a sync tool, say)',
|
|
136
136
|
'settings-changed': 'a settings file Atelier writes in this vault (the plugin list or a plugin setting under .obsidian) changed while the view was published, by Obsidian, another program or you, and was not written over; it is retried automatically, reading the file as it is then',
|
|
137
|
+
'selection-history-shortened': 'the selection history no longer follows the machine-private continuity evidence, so nothing is published. Put the removed records back, or explicitly confirm the generation again through the personal workspace selection operation. One fresh confirmation accepts the shorter history if the generation is still eligible. Restarting the service preserves this refusal.',
|
|
137
138
|
'vault-inside-another-vault': 'Obsidian lists another vault at a folder that contains this view\'s vault; open never adds a vault inside another one, and never sends a call that could reach that vault instead: remove that vault from Obsidian\'s vault list, or keep Atelier\'s data root outside that folder, then open again',
|
|
138
139
|
})
|
|
139
140
|
|
|
@@ -252,7 +253,7 @@ export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState,
|
|
|
252
253
|
freshness: entry === null ? null : { state: entry.state, reason: entry.reason, verified: entry.verified, generationId: entry.generationId, preparedGenerationId: entry.preparedGenerationId, heldNoteCount: entry.heldNotes.length, retainedEdits: entry.retainedEdits, checkedAt: entry.checkedAt },
|
|
253
254
|
readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
|
|
254
255
|
pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
|
|
255
|
-
// Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/obsidian-contract.md.
|
|
256
|
+
// Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/reference/obsidian-contract.md.
|
|
256
257
|
...(entry?.diagnostics ? { diagnostics: entry.diagnostics } : {}),
|
|
257
258
|
vaultRoot: verification.vaultRoot ?? null,
|
|
258
259
|
}
|
|
@@ -358,7 +359,7 @@ function settingsWritten(written) {
|
|
|
358
359
|
// again for a view the app kept from being published, and reports one outcome.
|
|
359
360
|
export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIMITIVES, lifecycleRules = LIFECYCLE_PRIMITIVES) {
|
|
360
361
|
const {
|
|
361
|
-
loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, quitter = null, restartApp = false, service = {}, consent, allowStale = false, extensions = null,
|
|
362
|
+
loadProject, locateProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, quitter = null, restartApp = false, service = {}, consent, allowStale = false, extensions = null,
|
|
362
363
|
tickTimeoutMs = 120 * 1000, appWaitMs = 30 * 1000, appPollMs = 500, sleep = defaultSleep, monotonic = () => Date.now(),
|
|
363
364
|
env = process.env, platform = process.platform, probeTimeoutMs,
|
|
364
365
|
} = options
|
|
@@ -382,7 +383,7 @@ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIM
|
|
|
382
383
|
}
|
|
383
384
|
if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
|
|
384
385
|
const scopeId = resolveScope(enablement, requestedScope)
|
|
385
|
-
const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
|
|
386
|
+
const lifecycle = { loadProject, ...(locateProject === undefined ? {} : { locateProject }), dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
|
|
386
387
|
|
|
387
388
|
// 1. The owned service: reconnect, or start. Never adopt; a runtime of ours is replaced only when it runs an earlier
|
|
388
389
|
// release, under the consent already recorded, when a tick is asked of it (requestServiceTick).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { PersonalWorkspaceRefusal } from '@mnstry/atelier/personal-workspace'
|
|
4
|
+
import { ensureLocalState } from '@mnstry/atelier/project'
|
|
5
|
+
import { assertPersonalSelection, loadBoundProject, loadBoundProjectOffThread, notePersonalSelectionHead, personalSelectionOf, pinPersonalSelection } from '../../projection/obsidian/personal-workspace.mjs'
|
|
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'
|
|
10
|
+
|
|
11
|
+
// A person's confirmed selection, bound to the maintenance runtime.
|
|
12
|
+
//
|
|
13
|
+
// A personal home keeps a history of the generations its person confirmed
|
|
14
|
+
// (`selectPersonalGeneration` of the personal-workspace module). The loaders
|
|
15
|
+
// here follow that history instead of naming a generation themselves:
|
|
16
|
+
//
|
|
17
|
+
// read the head -> load the generation it names -> read the head again
|
|
18
|
+
//
|
|
19
|
+
// The head is read from the history alone, in the caller's thread: one
|
|
20
|
+
// directory listing and one bounded read per record. Whether the generation
|
|
21
|
+
// may be used is composition's to decide, as for any binding: in a worker for
|
|
22
|
+
// a service, in the calling thread for a command run once. A head that moved
|
|
23
|
+
// while the generation was loaded is another selection, and refuses
|
|
24
|
+
// `personal-selection-changed`.
|
|
25
|
+
//
|
|
26
|
+
// Nothing is confirmed here and nothing is chosen for the person. A history
|
|
27
|
+
// with no record, one that does not hold, a home that cannot be read and a
|
|
28
|
+
// generation its composition refuses each refuse as a maintenance refusal
|
|
29
|
+
// with the module's own code (`nothing-selected`, `selection-history-corrupt`,
|
|
30
|
+
// `personal-home-unavailable`, `root-missing`, `stale-generation` and the
|
|
31
|
+
// rest). There is no project to fall back to: an engine whose loader refuses
|
|
32
|
+
// publishes nothing, and every view it knows says why.
|
|
33
|
+
//
|
|
34
|
+
// A loaded project is pinned to the record it was loaded under
|
|
35
|
+
// (pinPersonalSelection), so the engine observes that record and the place of
|
|
36
|
+
// the next one, and loads again at the tick after a confirmation or after the
|
|
37
|
+
// history was cut short. At every full reconciliation the history is read
|
|
38
|
+
// again, and a project whose record it no longer ends with is refused.
|
|
39
|
+
//
|
|
40
|
+
// A history never gets shorter through the module: selections are appended and
|
|
41
|
+
// nothing removes one. So a history that ends at a lower record than this
|
|
42
|
+
// process has read it ending at was cut short by something else, and following
|
|
43
|
+
// it back could publish what nobody confirmed after the records removed. Every
|
|
44
|
+
// read of the history's end is noted (notePersonalSelectionHead): the first
|
|
45
|
+
// read here, the read once the generation is loaded, the recheck of a project
|
|
46
|
+
// already loaded, and a read the engine asks for when its guard stops a view
|
|
47
|
+
// because an observed file changed or could not be read (once for views
|
|
48
|
+
// stopped one after another). A
|
|
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
|
|
51
|
+
// its view says `mixed-read`. An engine whose project is pinned below the
|
|
52
|
+
// highest record noted loads it again at its next tick
|
|
53
|
+
// (personalSelectionBehind). A record added and removed between two of those
|
|
54
|
+
// reads was never read, and does not count.
|
|
55
|
+
//
|
|
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.
|
|
60
|
+
//
|
|
61
|
+
// Who may see a vault stays the machine's setting: the engine initializes an undecided personal binding as owner only.
|
|
62
|
+
//
|
|
63
|
+
// Where the workspace is does not wait for a load: a bound project's folder is
|
|
64
|
+
// its private home, so the home alone locates it (locatePersonalHome). A
|
|
65
|
+
// service resolves its workspace from that at once, and starts, whether or not
|
|
66
|
+
// a selection is confirmed; its engine then awaits the loader at each tick.
|
|
67
|
+
|
|
68
|
+
// The decisions the oracles in test/obsidian-personal-selection.test.mjs are
|
|
69
|
+
// sensitive to. Production always uses these; a test hands a loader a broken
|
|
70
|
+
// one (`rules`) to prove that its oracle can fail.
|
|
71
|
+
export const SELECTION_PRIMITIVES = Object.freeze({
|
|
72
|
+
// A history that ends at a lower record than this process has read is refused, at every read of it.
|
|
73
|
+
refusesShortened: true,
|
|
74
|
+
// The head is read again once the generation is loaded.
|
|
75
|
+
readsHeadAgain: true,
|
|
76
|
+
// What the loaded project is pinned for (pinPersonalSelection).
|
|
77
|
+
pin: Object.freeze({ observe: true, recheck: true }),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const SOURCE = Object.freeze({ source: 'personal-workspace' })
|
|
81
|
+
const LOCATED = Symbol('atelier.obsidian.located-personal-home')
|
|
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 })
|
|
131
|
+
let head
|
|
132
|
+
try { head = readPersonalSelectionSnapshot({ personalHome }) } catch (error) {
|
|
133
|
+
if (!(error instanceof PersonalWorkspaceRefusal)) throw error
|
|
134
|
+
refuse(error.code, 'the selection history of the personal workspace could not be read; nothing is loaded from it', SOURCE)
|
|
135
|
+
}
|
|
136
|
+
const continuity = continuityFor(options)
|
|
137
|
+
const note = (current) => { if (rules.refusesShortened) continuity.note(current) }
|
|
138
|
+
notePersonalSelectionHead(personalHome, head, { ...rules, note })
|
|
139
|
+
if (head.selected === null) refuse('nothing-selected', 'no generation of the personal workspace is confirmed; nothing is loaded until one is', SOURCE)
|
|
140
|
+
return { head, note }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pinned(project, { head, note }, rules) {
|
|
144
|
+
pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened, note })
|
|
145
|
+
if (rules.readsHeadAgain) assertPersonalSelection(project)
|
|
146
|
+
return project
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// What the workspace of a bound personal home is resolved from, without loading anything: a project that holds the home
|
|
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.
|
|
153
|
+
export function locatePersonalHome({ personalHome } = {}) {
|
|
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)
|
|
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 }
|
|
165
|
+
located.localState = ensureLocalState(located, { write: false, env: { PATH: process.env.PATH } })
|
|
166
|
+
return located
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The personal home a located or a loaded project follows, which a service it starts or a login item it installs is
|
|
170
|
+
// given instead of a project (--personal-home); null for any other project.
|
|
171
|
+
export const personalHomeOf = (project) => project?.[LOCATED] ?? personalSelectionOf(project)?.personalHome ?? null
|
|
172
|
+
export { personalSelectionOf }
|
|
173
|
+
|
|
174
|
+
// The project of the confirmed selection, for a loader that may block (a command run once). `bind` and `rules` are
|
|
175
|
+
// test seams.
|
|
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)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The same, for a loader on a service's event loop: the composition runs in a worker, and the engine awaits it.
|
|
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)
|
|
187
|
+
}
|