@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.12
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 +344 -0
- package/README.md +10 -6
- package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
- package/contracts/compat-baseline.json +1 -1
- package/docs/assurance-controls.md +3 -2
- package/docs/install.md +3 -3
- package/docs/local-services.md +62 -11
- package/docs/obsidian-contract.md +669 -35
- package/docs/obsidian-plugin.md +585 -0
- package/docs/obsidian.md +233 -50
- package/docs/release-engineering.md +7 -1
- package/docs/upgrade.md +23 -3
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
- package/fixtures/obsidian/edits/workspace.json +1 -1
- package/fixtures/obsidian/materialization/expected-full.json +21 -21
- package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
- package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
- package/package.json +7 -3
- package/plugins/obsidian/main.js +444 -0
- package/plugins/obsidian/manifest.json +9 -0
- package/plugins/obsidian/styles.css +22 -0
- package/src/commands/obsidian.mjs +89 -10
- package/src/contracts/corpus.mjs +6 -0
- package/src/egress/forbidden-egress.mjs +2 -0
- package/src/project/private-state.mjs +10 -1
- package/src/projection/obsidian/contracts.mjs +224 -18
- package/src/projection/obsidian/edits/apply.mjs +12 -7
- package/src/projection/obsidian/edits/regions.mjs +137 -42
- package/src/projection/obsidian/materialize/index.mjs +6 -3
- package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
- package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
- package/src/projection/obsidian/materialize/redaction.mjs +327 -0
- package/src/projection/obsidian/materialize/settings.mjs +113 -34
- package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
- package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
- package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
- package/src/projection/obsidian/proposals/observation.mjs +10 -5
- package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
- package/src/projection/obsidian/publication/index.mjs +2 -1
- package/src/projection/obsidian/publication/publisher.mjs +173 -26
- package/src/projection/obsidian/publication/transport.mjs +63 -8
- package/src/projection/obsidian/publication/vault-list.mjs +199 -0
- package/src/projection/obsidian/recovery/restart.mjs +3 -1
- package/src/projection/obsidian/recovery/store.mjs +11 -4
- package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
- package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
- package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
- package/src/runtime/obsidian/app-capability.mjs +104 -12
- package/src/runtime/obsidian/app-production-seams.mjs +126 -39
- package/src/runtime/obsidian/app-registration.mjs +166 -0
- package/src/runtime/obsidian/engine.mjs +127 -11
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +5 -1
- package/src/runtime/obsidian/launch-plan.mjs +88 -0
- package/src/runtime/obsidian/lifecycle.mjs +105 -14
- package/src/runtime/obsidian/opening.mjs +227 -55
- package/src/runtime/obsidian/pending-edits.mjs +10 -0
- package/src/runtime/obsidian/plugin-channel.mjs +281 -0
- package/src/runtime/obsidian/plugin-choice.mjs +170 -0
- package/src/runtime/obsidian/plugin-drift.mjs +76 -0
- package/src/runtime/obsidian/plugin-presence.mjs +48 -0
- package/src/runtime/obsidian/service-main.mjs +18 -5
- package/src/runtime/obsidian/service-record.mjs +36 -0
- package/src/runtime/obsidian/service-server.mjs +50 -12
- package/src/runtime/obsidian/service.mjs +100 -6
- package/src/runtime/obsidian/state-store.mjs +13 -1
- package/src/server/local-sidecar.mjs +24 -5
- package/src/server/server.mjs +18 -5
- package/src/upgrade/upgrade.mjs +1 -1
- package/templates/external-project-workspace/pack-lifecycle.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
5
6
|
import { ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
|
|
6
7
|
import { canonicalJson, closedObject } from './documents.mjs'
|
|
@@ -60,6 +61,41 @@ export function executableIdentity(entryPath) {
|
|
|
60
61
|
return { path: resolved, digest: `sha256:${createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}` }
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// The package this module ships in.
|
|
65
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../../../', import.meta.url))
|
|
66
|
+
const byName = (left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
|
|
67
|
+
const releases = new Map()
|
|
68
|
+
|
|
69
|
+
// The release that runs, beyond its entry module: the version of the package
|
|
70
|
+
// and a digest of the runtime it ships, by relative path and content. That is
|
|
71
|
+
// what the service loads: every module under `src/`, the `contracts/` it
|
|
72
|
+
// reads, and the plugin under `plugins/` it publishes into every vault it
|
|
73
|
+
// maintains; outside those it reads nothing else of the package but the
|
|
74
|
+
// version in `package.json`. A release that changes any of it, and not only
|
|
75
|
+
// the entry, differs. A package without `plugins/` reads as one with an empty
|
|
76
|
+
// one. Computed once per process and package root, so a service keeps the
|
|
77
|
+
// identity of the code it loaded.
|
|
78
|
+
export function releaseIdentity({ root = PACKAGE_ROOT } = {}) {
|
|
79
|
+
if (!releases.has(root)) {
|
|
80
|
+
const hash = createHash('sha256')
|
|
81
|
+
const walk = (relative, { optional = false } = {}) => {
|
|
82
|
+
let entries
|
|
83
|
+
try { entries = fs.readdirSync(path.join(root, relative), { withFileTypes: true }) } catch (error) { if (optional && error.code === 'ENOENT') return; throw error }
|
|
84
|
+
for (const entry of entries.sort(byName)) {
|
|
85
|
+
const child = `${relative}/${entry.name}`
|
|
86
|
+
if (entry.isDirectory()) walk(child)
|
|
87
|
+
else if (entry.isFile()) hash.update(`${child}\0${createHash('sha256').update(fs.readFileSync(path.join(root, child))).digest('hex')}\n`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const part of ['src', 'contracts']) walk(part)
|
|
91
|
+
walk('plugins', { optional: true })
|
|
92
|
+
let version = null
|
|
93
|
+
try { const read = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; version = typeof read === 'string' ? read : null } catch { version = null }
|
|
94
|
+
releases.set(root, Object.freeze({ version, digest: `sha256:${hash.digest('hex')}` }))
|
|
95
|
+
}
|
|
96
|
+
return releases.get(root)
|
|
97
|
+
}
|
|
98
|
+
|
|
63
99
|
// ---------------------------------------------------------------------------
|
|
64
100
|
// The adapter record
|
|
65
101
|
// ---------------------------------------------------------------------------
|
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import http from 'node:http'
|
|
3
|
+
import { isContractIdentifier } from '../../projection/obsidian/contracts.mjs'
|
|
4
|
+
import { PLUGIN_MAX_REQUEST_BYTES, pluginCommandOf, validatePluginRequest } from '../../projection/obsidian/plugin-bridge/channel.mjs'
|
|
3
5
|
import { requestHeader, sameOrigin } from '../../server/security.mjs'
|
|
4
6
|
import { isPlainObject } from './documents.mjs'
|
|
5
7
|
import { HEALTH_SCHEMA, LOOPBACK_HOSTS, authorityOf } from './service-client.mjs'
|
|
6
8
|
|
|
7
|
-
// The listener of the maintenance service: four fixed operations
|
|
8
|
-
//
|
|
9
|
-
// takes a path, a name or
|
|
9
|
+
// The listener of the maintenance service: four fixed operations, the five
|
|
10
|
+
// fixed commands of Atelier's plugin, and nothing else. There is no file
|
|
11
|
+
// serving, no command, no evaluation and no route that takes a path, a name or
|
|
12
|
+
// code from a request.
|
|
10
13
|
//
|
|
11
14
|
// GET /health who answers here; open, and says nothing else
|
|
12
15
|
// GET /status service and per-view freshness summary; bearer required
|
|
13
16
|
// POST /tick run one tick now; bearer required
|
|
14
17
|
// POST /stop finish the tick in flight and exit; bearer required
|
|
15
18
|
//
|
|
19
|
+
// POST /plugin/challenge | /plugin/hello | /plugin/lease | /plugin/release |
|
|
20
|
+
// /plugin/status
|
|
21
|
+
// Atelier's plugin inside one vault, authenticated by a
|
|
22
|
+
// handshake over that vault's key (plugin-bridge/channel.mjs)
|
|
23
|
+
//
|
|
16
24
|
// Every request, before its operation is even looked up:
|
|
17
25
|
//
|
|
18
26
|
// - `Host` is exactly the literal loopback authority this listener is bound
|
|
19
27
|
// to, so a name that resolves to loopback (DNS rebinding) is refused;
|
|
20
28
|
// - a `Sec-Fetch-Site` other than `none` or `same-origin`, or an `Origin`
|
|
21
29
|
// that is not this listener itself, is refused: no web page drives this;
|
|
22
|
-
// - the path is one of the
|
|
30
|
+
// - the path is one of the nine, exactly, with no query; the method is the
|
|
23
31
|
// one that path has.
|
|
24
32
|
//
|
|
25
|
-
// Everything but health needs the per-runtime
|
|
26
|
-
// in the owner-only record. A POST body is JSON
|
|
27
|
-
// runtime it is meant for, so a request aimed at an
|
|
28
|
-
// same port does nothing.
|
|
33
|
+
// Everything but health and the plugin's commands needs the per-runtime
|
|
34
|
+
// random bearer that exists only in the owner-only record. A POST body is JSON
|
|
35
|
+
// of at most 1 KiB naming the runtime it is meant for, so a request aimed at an
|
|
36
|
+
// earlier runtime on the same port does nothing (409). A tick may also name one
|
|
37
|
+
// view (`scopeId`, a contract identifier), which is then prepared and published
|
|
38
|
+
// once more on that tick; any other member is refused (400,
|
|
39
|
+
// `request-member-unknown`).
|
|
40
|
+
//
|
|
41
|
+
// A plugin command carries no bearer: the plugin never sends its vault's key.
|
|
42
|
+
// Its body, JSON of at most 1 KiB with exactly the fields of its command, is
|
|
43
|
+
// judged by the plugin channel (plugin-channel.mjs): a challenge the service
|
|
44
|
+
// answers only for a key it holds, a hello that proves the same key, and
|
|
45
|
+
// session commands under the key the handshake derived. What it grants is
|
|
46
|
+
// these commands for that one vault. The runtime bearer is not accepted there,
|
|
47
|
+
// and nothing a plugin holds reaches status, tick or stop.
|
|
29
48
|
|
|
30
49
|
export const MAX_REQUEST_BYTES = 1024
|
|
31
50
|
export const SERVICE_OPERATIONS = Object.freeze({ '/health': 'GET', '/status': 'GET', '/tick': 'POST', '/stop': 'POST' })
|
|
@@ -35,6 +54,7 @@ const digestOf = (value) => createHash('sha256').update(String(value)).digest()
|
|
|
35
54
|
// The decisions the request oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
|
|
36
55
|
export const SERVER_PRIMITIVES = Object.freeze({
|
|
37
56
|
bearerMatches: (presented, expected) => typeof presented === 'string' && presented !== '' && timingSafeEqual(digestOf(presented), digestOf(expected)),
|
|
57
|
+
maxPluginRequestBytes: PLUGIN_MAX_REQUEST_BYTES,
|
|
38
58
|
hostMatches: (header, authority) => header === authority,
|
|
39
59
|
originAllowed(headers, authority) {
|
|
40
60
|
const site = requestHeader(headers, 'sec-fetch-site').toLowerCase()
|
|
@@ -73,7 +93,9 @@ function readBoundedJson(request, limit) {
|
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
// identity: { serviceName, workspaceId, runtimeId, pid, host, port, executableDigest, startedAt }
|
|
76
|
-
// operations: { healthStatus(), status(), tick(), stop() }
|
|
96
|
+
// operations: { healthStatus(), status(), tick(), stop() }, and, where the
|
|
97
|
+
// service holds the plugin channel, { plugin(command, { body, authority }) ->
|
|
98
|
+
// { statusCode, body } }
|
|
77
99
|
export function createServiceServerForOracleTests({ identity, bearer, operations }, primitives = SERVER_PRIMITIVES) {
|
|
78
100
|
if (!LOOPBACK_HOSTS.includes(identity?.host)) throw new TypeError('the service listens on a literal loopback address only')
|
|
79
101
|
if (typeof bearer !== 'string' || bearer.length < 32) throw new TypeError('the service needs its random bearer')
|
|
@@ -83,6 +105,19 @@ export function createServiceServerForOracleTests({ identity, bearer, operations
|
|
|
83
105
|
const authority = authorityOf(identity.host, identity.port)
|
|
84
106
|
if (!rules.hostMatches(requestHeader(request.headers, 'host'), authority)) return send(response, 403, { error: 'host-not-this-loopback-listener' })
|
|
85
107
|
if (!rules.originAllowed(request.headers, authority)) return send(response, 403, { error: 'cross-site-request' })
|
|
108
|
+
const presented = /^Bearer ([A-Za-z0-9_-]+)$/.exec(requestHeader(request.headers, 'authorization'))?.[1] ?? null
|
|
109
|
+
const command = typeof operations.plugin === 'function' ? pluginCommandOf(request.url) : null
|
|
110
|
+
if (command !== null) {
|
|
111
|
+
if (request.method !== 'POST') return send(response, 405, { error: 'method-not-allowed' })
|
|
112
|
+
// No credential travels in a header here: a request that sends one was not made by the plugin.
|
|
113
|
+
if (requestHeader(request.headers, 'authorization') !== '') return send(response, 400, { error: 'plugin-command-takes-no-bearer' })
|
|
114
|
+
const payload = await readBoundedJson(request, rules.maxPluginRequestBytes)
|
|
115
|
+
if (!payload.ok) return send(response, payload.statusCode, { error: payload.code })
|
|
116
|
+
const checked = validatePluginRequest(command, payload.body)
|
|
117
|
+
if (!checked.ok) return send(response, checked.code === 'protocol-unsupported' ? 409 : 400, { error: checked.code })
|
|
118
|
+
const answer = await operations.plugin(command, { body: checked.body, authority })
|
|
119
|
+
return send(response, answer.statusCode, answer.body)
|
|
120
|
+
}
|
|
86
121
|
const method = Object.hasOwn(SERVICE_OPERATIONS, request.url) ? SERVICE_OPERATIONS[request.url] : null
|
|
87
122
|
if (method === null) return send(response, 404, { error: 'unknown-operation' })
|
|
88
123
|
if (request.method !== method) return send(response, 405, { error: 'method-not-allowed' })
|
|
@@ -90,13 +125,16 @@ export function createServiceServerForOracleTests({ identity, bearer, operations
|
|
|
90
125
|
const { serviceName, workspaceId, runtimeId, pid, host, port, executableDigest, startedAt } = identity
|
|
91
126
|
return send(response, 200, { schema: HEALTH_SCHEMA, serviceName, workspaceId, runtimeId, pid, host, port, executableDigest, startedAt, status: operations.healthStatus() })
|
|
92
127
|
}
|
|
93
|
-
const presented = /^Bearer ([A-Za-z0-9_-]+)$/.exec(requestHeader(request.headers, 'authorization'))?.[1] ?? null
|
|
94
128
|
if (!rules.bearerMatches(presented, bearer)) return send(response, 401, { error: 'bearer-required' })
|
|
95
129
|
if (request.url === '/status') return send(response, 200, await operations.status())
|
|
96
130
|
const payload = await readBoundedJson(request, rules.maxRequestBytes)
|
|
97
131
|
if (!payload.ok) return send(response, payload.statusCode, { error: payload.code })
|
|
98
|
-
|
|
99
|
-
if (
|
|
132
|
+
const { runtimeId, scopeId, ...unknown } = payload.body
|
|
133
|
+
if (runtimeId !== identity.runtimeId) return send(response, 409, { error: 'request-names-another-runtime' })
|
|
134
|
+
// A member the operation does not take: a view on a stop, or anything else.
|
|
135
|
+
if (Object.keys(unknown).length > 0 || (scopeId !== undefined && request.url !== '/tick')) return send(response, 400, { error: 'request-member-unknown' })
|
|
136
|
+
if (scopeId !== undefined && !isContractIdentifier(scopeId)) return send(response, 400, { error: 'request-invalid' })
|
|
137
|
+
if (request.url === '/tick') return send(response, 200, await operations.tick(scopeId === undefined ? {} : { scopeId }))
|
|
100
138
|
// The answer leaves first; the service then finishes its tick and exits.
|
|
101
139
|
response.once('finish', () => { void operations.stop() })
|
|
102
140
|
return send(response, 202, { stopping: true, runtimeId: identity.runtimeId, pid: identity.pid })
|
|
@@ -2,18 +2,24 @@ import { randomBytes as cryptoRandomBytes } from 'node:crypto'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import { AtelierDiagnosticError } from '../../project/config.mjs'
|
|
4
4
|
import { ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
|
|
5
|
+
import { prepareView as productionPrepareView } from '../../projection/obsidian/materialize/index.mjs'
|
|
6
|
+
import { preparePluginFiles } from '../../projection/obsidian/plugin-bridge/bundle.mjs'
|
|
7
|
+
import { publishView as productionPublishView } from '../../projection/obsidian/publication/publisher.mjs'
|
|
5
8
|
import { isoTime } from './documents.mjs'
|
|
6
9
|
import { createMaintenanceEngine } from './engine.mjs'
|
|
7
10
|
import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
|
|
8
11
|
import { assertOutsideRepositories, protectedRoots, readLocalPointer, resolveDataRoot, workspaceStateRoot } from './machine-settings.mjs'
|
|
12
|
+
import { createPluginChannel, createPluginSessions, ensurePluginBearer, pluginPresence } from './plugin-channel.mjs'
|
|
13
|
+
import { confirmPluginEntry, confirmPluginSeen, currentPluginChoice, decidePluginChoice, vaultFilePresent, viewVaultRoot } from './plugin-choice.mjs'
|
|
14
|
+
import { createPluginDriftObserver } from './plugin-drift.mjs'
|
|
9
15
|
import { isProcessAlive } from './private-lock.mjs'
|
|
10
16
|
import { probeHealth } from './service-client.mjs'
|
|
11
17
|
import {
|
|
12
|
-
SERVICE_ERROR_SCHEMA, executableIdentity, readLastServiceError, readServiceRecord, readServiceSettings, removeServiceRecord, serviceNameFor, servicePaths,
|
|
18
|
+
SERVICE_ERROR_SCHEMA, executableIdentity, readLastServiceError, readServiceRecord, readServiceSettings, releaseIdentity, removeServiceRecord, serviceNameFor, servicePaths,
|
|
13
19
|
writeLastServiceError, writeServiceRecord,
|
|
14
20
|
} from './service-record.mjs'
|
|
15
21
|
import { createServiceServer } from './service-server.mjs'
|
|
16
|
-
import { createMaintenanceStateStore } from './state-store.mjs'
|
|
22
|
+
import { OPEN_EDIT_STATES, createMaintenanceStateStore } from './state-store.mjs'
|
|
17
23
|
import { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
|
|
18
24
|
import { createFsWatcherFactory } from './watchers.mjs'
|
|
19
25
|
|
|
@@ -33,6 +39,13 @@ import { createFsWatcherFactory } from './watchers.mjs'
|
|
|
33
39
|
// publication that was cut short is settled by the publisher's own restart
|
|
34
40
|
// recovery on a later tick, never by deleting what it left behind.
|
|
35
41
|
//
|
|
42
|
+
// It also holds the plugin channel (plugin-channel.mjs). Every view it
|
|
43
|
+
// prepares carries Atelier's plugin, whose data file names this listener and
|
|
44
|
+
// the view's bearer, unless the person turned the plugin off in that vault
|
|
45
|
+
// (plugin-choice.mjs): then the entry is left alone and only the plugin files
|
|
46
|
+
// that are still there are kept current. A plugin that holds a view open is
|
|
47
|
+
// reported by status and tells the adapter factory the app version it runs in.
|
|
48
|
+
//
|
|
36
49
|
// `adapterFactory` has no default here either: whoever starts the service
|
|
37
50
|
// decides whether it may reach a running app.
|
|
38
51
|
|
|
@@ -98,6 +111,8 @@ export async function runMaintenanceService(options = {}) {
|
|
|
98
111
|
const identity = { serviceName: serviceNameFor(workspaceId), workspaceId, runtimeId, pid, host, port, executableDigest: executable.digest, startedAt }
|
|
99
112
|
const bearer = randomBytes(32).toString('base64url')
|
|
100
113
|
const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
|
|
114
|
+
// Session identities live in memory only and are always random, whatever `randomBytes` the service was given.
|
|
115
|
+
const pluginSessions = createPluginSessions()
|
|
101
116
|
|
|
102
117
|
let stopping = false
|
|
103
118
|
let consecutiveFailures = 0
|
|
@@ -105,7 +120,49 @@ export async function runMaintenanceService(options = {}) {
|
|
|
105
120
|
let settleDone
|
|
106
121
|
const done = new Promise((resolve) => { settleDone = resolve })
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
// Every prepared view carries the plugin with this listener's address and the view's bearer, as the person's choice
|
|
124
|
+
// for that vault allows: the choice is read, and recorded when the vault shows it changed, from the community plugin
|
|
125
|
+
// list as it is now, and those very bytes travel with the view. A bearer or a choice that cannot be kept leaves that
|
|
126
|
+
// view without the plugin for this tick; the view itself is prepared as ever.
|
|
127
|
+
const pluginFor = (scopeId) => {
|
|
128
|
+
try {
|
|
129
|
+
const vaultRoot = viewVaultRoot(workspaceRoot, scopeId)
|
|
130
|
+
const { choice, community } = decidePluginChoice({ workspaceRoot, workspaceId, scopeId, vaultRoot, clock })
|
|
131
|
+
const off = choice.state === 'off'
|
|
132
|
+
const bearer = ensurePluginBearer({ workspaceRoot, workspaceId, scopeId, randomBytes, clock })
|
|
133
|
+
if (pluginChannel.bearers().get(scopeId) !== bearer) pluginChannel.bearersChanged()
|
|
134
|
+
const plugin = preparePluginFiles({ channel: { host, port }, scopeId, bearer, onlyIfPresent: off })
|
|
135
|
+
// Turned off: the files still there are kept current; a folder the person removed is not made again.
|
|
136
|
+
const files = off ? plugin.files.filter((file) => vaultFilePresent(vaultRoot, file.path)) : plugin.files
|
|
137
|
+
const kept = new Set(files.map((file) => file.path))
|
|
138
|
+
const unreadable = community.file.state === 'unreadable'
|
|
139
|
+
return {
|
|
140
|
+
files, ownership: { ...plugin.ownership, files: plugin.ownership.files.filter((entry) => kept.has(entry.path)) },
|
|
141
|
+
community: off || unreadable ? { entry: 'withheld', reason: off ? 'turned-off-in-this-vault' : 'list-unreadable' } : { entry: 'owned', existing: community.file.bytes },
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
log({ at: isoTime(clock), event: 'plugin-not-prepared', code: errorCode(error), name: errorName(error) })
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const prepareWithPlugin = (input) => (engineOptions.seams?.prepareView ?? productionPrepareView)({ ...input, plugin: pluginFor(input.scope.scopeId) })
|
|
149
|
+
// An entry offered to a vault and now in place is confirmed: from then on, a list without it is the person's decision.
|
|
150
|
+
const publishAndConfirm = async (input) => {
|
|
151
|
+
const result = await (engineOptions.seams?.publishView ?? productionPublishView)(input)
|
|
152
|
+
try { confirmPluginEntry({ workspaceRoot, workspaceId, scopeId: input.recoveryStore.scopeId, result, clock }) } catch (error) {
|
|
153
|
+
log({ at: isoTime(clock), event: 'plugin-entry-not-confirmed', code: errorCode(error), name: errorName(error) })
|
|
154
|
+
}
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
157
|
+
// The app version a live plugin reports counts as checked for the view it holds open, while one launch of the plugin
|
|
158
|
+
// alone holds it: with two apps holding the vault, which one the command-line tool reaches is unknown.
|
|
159
|
+
const pluginReportOf = (scopeId) => { const report = pluginSessions.report(scopeId); return report?.instances === 1 ? report : null }
|
|
160
|
+
// The engine also asks the factory itself (`forget`, on a tick somebody asked for): the wrapper keeps its methods.
|
|
161
|
+
const pluginAwareAdapterFactory = Object.assign((input) => adapterFactory({ ...input, pluginReport: typeof input?.scope?.scopeId === 'string' ? pluginReportOf(input.scope.scopeId) : null }), adapterFactory)
|
|
162
|
+
const engine = createEngine({
|
|
163
|
+
watcherFactory: createFsWatcherFactory(), ...engineOptions, seams: { ...(engineOptions.seams ?? {}), prepareView: prepareWithPlugin, publishView: publishAndConfirm },
|
|
164
|
+
loadProject, dataRoot, adapterFactory: pluginAwareAdapterFactory, clock, env, platform, lockOwner: { host, port, runtimeId },
|
|
165
|
+
})
|
|
109
166
|
|
|
110
167
|
const recordIsOurs = () => {
|
|
111
168
|
try { const record = readServiceRecord({ workspaceRoot, workspaceId }); return record !== null && record.runtimeId === runtimeId && record.pid === pid } catch { return false }
|
|
@@ -128,11 +185,21 @@ export async function runMaintenanceService(options = {}) {
|
|
|
128
185
|
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 } })
|
|
129
186
|
}
|
|
130
187
|
|
|
188
|
+
// A plugin file a view's committed generation pins that is not on disk as pinned has the view prepared again, so the
|
|
189
|
+
// publisher writes it again; a drift is asked about once, not at every tick.
|
|
190
|
+
const pluginDrift = createPluginDriftObserver({ workspaceRoot, workspaceId })
|
|
191
|
+
const askForDriftedViews = () => {
|
|
192
|
+
try { for (const scopeId of pluginDrift.observe([...pluginChannel.bearers().keys()])) engine.requestPreparation(scopeId) } catch (error) {
|
|
193
|
+
log({ at: isoTime(clock), event: 'plugin-drift-not-read', code: errorCode(error), name: errorName(error) })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
131
197
|
const loop = createTickLoop({
|
|
132
198
|
intervalMs, maxBackoffMs, onOutcome: recordOutcome,
|
|
133
199
|
async tick() {
|
|
134
200
|
// The service runs only while its record names it: a replaced or removed record ends it, cleanly.
|
|
135
201
|
if (!recordIsOurs()) { void shutdown('record-no-longer-names-this-runtime'); return { state: 'stopping', reason: 'record-no-longer-names-this-runtime' } }
|
|
202
|
+
askForDriftedViews()
|
|
136
203
|
return engine.tick()
|
|
137
204
|
},
|
|
138
205
|
})
|
|
@@ -150,6 +217,26 @@ export async function runMaintenanceService(options = {}) {
|
|
|
150
217
|
}
|
|
151
218
|
|
|
152
219
|
const healthStatus = () => (stopping ? 'stopped' : consecutiveFailures > 0 ? 'degraded' : 'healthy')
|
|
220
|
+
|
|
221
|
+
// One view as the plugin is told about it: its freshness entry with held notes counted, and its open pending edits.
|
|
222
|
+
function pluginStatusOf(scopeId) {
|
|
223
|
+
let entry = null
|
|
224
|
+
try { entry = stateStore.readFreshness()?.scopes.find((item) => item.scopeId === scopeId) ?? null } catch (error) { if (!isTyped(error)) throw error; return { view: null, pendingEdits: null } }
|
|
225
|
+
let open = null
|
|
226
|
+
try { open = stateStore.readPendingEdits().edits.filter((edit) => edit.scopeId === scopeId && OPEN_EDIT_STATES.includes(edit.state)).length } catch (error) { if (!isTyped(error)) throw error }
|
|
227
|
+
const view = entry === null ? null : {
|
|
228
|
+
state: entry.state, reason: entry.reason, verified: entry.verified, generationId: entry.generationId, preparedGenerationId: entry.preparedGenerationId,
|
|
229
|
+
heldNoteCount: entry.heldNotes.length, retainedEdits: entry.retainedEdits, checkedAt: entry.checkedAt,
|
|
230
|
+
}
|
|
231
|
+
return { view, pendingEdits: open === null ? null : { open } }
|
|
232
|
+
}
|
|
233
|
+
// A plugin that holds a view open shows that its app has the view's entry: an entry only offered so far is confirmed.
|
|
234
|
+
const pluginSeen = (scopeId) => {
|
|
235
|
+
try { confirmPluginSeen({ workspaceRoot, workspaceId, scopeId, clock }) } catch (error) {
|
|
236
|
+
log({ at: isoTime(clock), event: 'plugin-entry-not-confirmed', code: errorCode(error), name: errorName(error) })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const pluginChannel = createPluginChannel({ workspaceRoot, workspaceId, runtimeId, sessions: pluginSessions, statusOf: pluginStatusOf, serviceStatus: healthStatus, onSessionOpened: pluginSeen })
|
|
153
240
|
const server = createServiceServer({
|
|
154
241
|
identity, bearer,
|
|
155
242
|
operations: {
|
|
@@ -158,14 +245,21 @@ export async function runMaintenanceService(options = {}) {
|
|
|
158
245
|
let lastError = null
|
|
159
246
|
try { lastError = readLastServiceError({ workspaceRoot, workspaceId }) } catch (error) { if (!isTyped(error)) throw error; lastError = { unreadable: error.code } }
|
|
160
247
|
if (lastError?.schema) { const { schema: _schema, workspaceId: _workspace, ...shown } = lastError; lastError = shown }
|
|
161
|
-
return {
|
|
248
|
+
return {
|
|
249
|
+
schema: SERVICE_STATUS_SCHEMA, service: { ...identity, status: healthStatus() }, loop: loop.state(), lastTick, lastError, freshness: freshnessSummary(),
|
|
250
|
+
plugins: pluginPresence({ sessions: pluginSessions, scopeIds: [...pluginChannel.bearers().keys()], entryOf: (scopeId) => currentPluginChoice({ workspaceRoot, workspaceId, scopeId }).state }),
|
|
251
|
+
...(typeof appStatus === 'function' ? { app: appStatus() } : {}),
|
|
252
|
+
}
|
|
162
253
|
},
|
|
163
|
-
|
|
254
|
+
// A tick asked for one view (`open` asks for its own) prepares and publishes that view once more.
|
|
255
|
+
async tick({ scopeId } = {}) {
|
|
256
|
+
if (scopeId !== undefined) engine.requestPreparation?.(scopeId)
|
|
164
257
|
const outcome = await loop.tickNow()
|
|
165
258
|
if (outcome.stopped) return { ok: false, stopped: true }
|
|
166
259
|
return outcome.ok ? { ok: true, ...summary(outcome.report) } : { ok: false, error: { code: errorCode(outcome.error), name: errorName(outcome.error) } }
|
|
167
260
|
},
|
|
168
261
|
stop: () => shutdown('stop-requested'),
|
|
262
|
+
plugin: (command, request) => pluginChannel.handle(command, request),
|
|
169
263
|
},
|
|
170
264
|
})
|
|
171
265
|
|
|
@@ -202,7 +296,7 @@ export async function runMaintenanceService(options = {}) {
|
|
|
202
296
|
workspaceRoot, workspaceId,
|
|
203
297
|
record: {
|
|
204
298
|
schema: 'atelier-obsidian-service-state/v1', contractVersion: '1.0.0', workspaceId, serviceName: identity.serviceName, host, port, runtimeId, pid,
|
|
205
|
-
executable: { path: executable.path, digest: executable.digest, ext: { runner: process.execPath } }, stateLocation: servicePaths(workspaceRoot).stateLocation,
|
|
299
|
+
executable: { path: executable.path, digest: executable.digest, ext: { runner: process.execPath, release: releaseIdentity() } }, stateLocation: servicePaths(workspaceRoot).stateLocation,
|
|
206
300
|
health: { status: 'healthy', checkedAt: startedAt }, consent: settings.consent, ext: { bearer },
|
|
207
301
|
},
|
|
208
302
|
})
|
|
@@ -47,6 +47,9 @@ const isDigest = (value) => typeof value === 'string' && DIGEST.test(value)
|
|
|
47
47
|
const isTimestamp = (value) => typeof value === 'string' && TIMESTAMP.test(value)
|
|
48
48
|
const isRelative = (value) => typeof value === 'string' && value !== '' && !value.startsWith('/') && !value.includes('\\') && !value.split('/').some((part) => part === '' || part === '.' || part === '..')
|
|
49
49
|
const isCount = (value) => Number.isInteger(value) && value >= 0
|
|
50
|
+
const isShortCode = (value) => typeof value === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(value)
|
|
51
|
+
// What a scope entry may say about notes of the view: a code, the rule, and which note or file; never a value.
|
|
52
|
+
export const MAX_FRESHNESS_DIAGNOSTICS = 100
|
|
50
53
|
|
|
51
54
|
function check(condition, code, message) {
|
|
52
55
|
if (!condition) refuse(code, message)
|
|
@@ -67,7 +70,7 @@ export function validateFreshness(document, workspaceId) {
|
|
|
67
70
|
check(Array.isArray(document.scopes), code, 'scopes must be a list')
|
|
68
71
|
const seen = new Set()
|
|
69
72
|
for (const scope of document.scopes) {
|
|
70
|
-
closedObject(scope, { required: ['scopeId', 'state', 'reason', 'generationId', 'preparedGenerationId', 'verified', 'heldNotes', 'changeClasses', 'retainedEdits', 'checkedAt'] }, code, 'a scope freshness entry')
|
|
73
|
+
closedObject(scope, { required: ['scopeId', 'state', 'reason', 'generationId', 'preparedGenerationId', 'verified', 'heldNotes', 'changeClasses', 'retainedEdits', 'checkedAt'], optional: ['diagnostics'] }, code, 'a scope freshness entry')
|
|
71
74
|
check(isIdentifier(scope.scopeId) && !seen.has(scope.scopeId), code, 'a scope identity is malformed or repeated')
|
|
72
75
|
seen.add(scope.scopeId)
|
|
73
76
|
check(FRESHNESS_STATES.includes(scope.state), code, 'a scope carries an unknown freshness state')
|
|
@@ -79,6 +82,15 @@ export function validateFreshness(document, workspaceId) {
|
|
|
79
82
|
check(scope.state !== 'current' || (scope.verified && scope.generationId !== null && scope.generationId === scope.preparedGenerationId && scope.heldNotes.length === 0), code, 'a scope claims current without an exact verified generation')
|
|
80
83
|
check(Array.isArray(scope.changeClasses) && scope.changeClasses.every((item) => CHANGE_CLASSES.includes(item)), code, 'changeClasses names an unknown class')
|
|
81
84
|
check(isCount(scope.retainedEdits) && isTimestamp(scope.checkedAt), code, 'a scope entry is malformed')
|
|
85
|
+
if (scope.diagnostics !== undefined) {
|
|
86
|
+
check(Array.isArray(scope.diagnostics) && scope.diagnostics.length <= MAX_FRESHNESS_DIAGNOSTICS, code, 'diagnostics must be a short list')
|
|
87
|
+
for (const item of scope.diagnostics) {
|
|
88
|
+
closedObject(item, { required: ['code'], optional: ['rule', 'repoId', 'nodeId', 'assetPath', 'notePath', 'filePath'] }, code, 'a diagnostic')
|
|
89
|
+
check(isShortCode(item.code) && (item.rule === undefined || isShortCode(item.rule)), code, 'a diagnostic names a malformed code or rule')
|
|
90
|
+
check(['repoId', 'nodeId', 'assetPath'].every((key) => item[key] === undefined || (typeof item[key] === 'string' && item[key] !== '')), code, 'a diagnostic names a malformed identity')
|
|
91
|
+
check(['notePath', 'filePath'].every((key) => item[key] === undefined || isRelative(item[key])), code, 'a diagnostic names a path that is not vault-relative')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
82
94
|
}
|
|
83
95
|
return document
|
|
84
96
|
}
|
|
@@ -5,6 +5,7 @@ import fs from 'node:fs'
|
|
|
5
5
|
import http from 'node:http'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { AtelierDiagnosticError } from '../project/config.mjs'
|
|
8
9
|
import {
|
|
9
10
|
atomicReplacePrivateText,
|
|
10
11
|
ensureContainedPrivateDirectory,
|
|
@@ -196,6 +197,25 @@ function capabilityContract() {
|
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
// A listen failure a person can fix by choosing another port becomes a typed
|
|
201
|
+
// diagnostic the CLI prints with that remedy. Any other failure is returned
|
|
202
|
+
// unchanged, so the CLI's own rule for unknown errors still decides what shows.
|
|
203
|
+
function listenFailure(error, port) {
|
|
204
|
+
if (error?.code === 'EADDRINUSE') {
|
|
205
|
+
return new AtelierDiagnosticError('port-in-use', `port ${port} is already in use`, {
|
|
206
|
+
hint: 'Pass --port=<free port> to choose another.',
|
|
207
|
+
cause: error,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
if (error?.code === 'EACCES') {
|
|
211
|
+
return new AtelierDiagnosticError('port-permission-denied', `permission to listen on port ${port} was denied`, {
|
|
212
|
+
hint: 'Pass --port=<free port from 1024 to 65535> to choose another; lower ports need elevated privileges.',
|
|
213
|
+
cause: error,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
return error
|
|
217
|
+
}
|
|
218
|
+
|
|
199
219
|
function doctorEnvelope() {
|
|
200
220
|
return {
|
|
201
221
|
ok: true,
|
|
@@ -662,14 +682,13 @@ export function createAtelierSidecarServer({
|
|
|
662
682
|
if (!isLoopbackHost(host)) {
|
|
663
683
|
return Promise.reject(new Error(`non-loopback listen host refused: ${host}`))
|
|
664
684
|
}
|
|
665
|
-
// A busy port is an ordinary condition, not a crash: surface
|
|
666
|
-
// rejection the CLI
|
|
685
|
+
// A busy or refused port is an ordinary condition, not a crash: surface
|
|
686
|
+
// it as a typed rejection the CLI prints with its remedy, instead of an
|
|
687
|
+
// unhandled 'error' event or a redacted internal error.
|
|
667
688
|
return new Promise((resolve, reject) => {
|
|
668
689
|
const onError = (error) => {
|
|
669
690
|
server.removeListener('listening', onListening)
|
|
670
|
-
reject(error
|
|
671
|
-
? new Error(`port ${listenPort} is already in use — pass --port=<free port> to choose another`)
|
|
672
|
-
: error)
|
|
691
|
+
reject(listenFailure(error, listenPort))
|
|
673
692
|
}
|
|
674
693
|
const onListening = () => {
|
|
675
694
|
server.removeListener('error', onError)
|
package/src/server/server.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveProjectConfig } from '../project/config.mjs'
|
|
1
|
+
import { AtelierDiagnosticError, resolveProjectConfig } from '../project/config.mjs'
|
|
2
2
|
import { createAtelierSidecarServer } from './local-sidecar.mjs'
|
|
3
3
|
|
|
4
4
|
function parseServerArgs(argv = []) {
|
|
@@ -11,6 +11,22 @@ function parseServerArgs(argv = []) {
|
|
|
11
11
|
return args
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// argv > PORT env > default. Supervisors that assign a free port (preview
|
|
15
|
+
// panes, dev harnesses) pass it via PORT, and an Atelier that ignores that
|
|
16
|
+
// fights whatever already holds the canonical port instead of coexisting.
|
|
17
|
+
// A value listen would refuse is refused here, naming where it came from;
|
|
18
|
+
// Node's own range error names neither the flag nor the variable.
|
|
19
|
+
function resolvePort(args, env = process.env) {
|
|
20
|
+
const [source, raw] = args.port ? ['--port', args.port] : env.PORT ? ['PORT', env.PORT] : [null, '8137']
|
|
21
|
+
const port = Number(raw)
|
|
22
|
+
if (Number.isInteger(port) && port >= 0 && port <= 65535) return port
|
|
23
|
+
throw new AtelierDiagnosticError('port-invalid', `${source} must be a whole number from 0 to 65535, got ${JSON.stringify(raw)}`, {
|
|
24
|
+
hint: source === 'PORT'
|
|
25
|
+
? 'Set PORT to a free port or unset it, or pass --port=<free port> to override it.'
|
|
26
|
+
: 'Pass a free port, for example --port=8138.',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export async function runServerCommand(argv = process.argv.slice(2)) {
|
|
15
31
|
const args = parseServerArgs(argv)
|
|
16
32
|
const project = resolveProjectConfig({ argv: args.projectArgs })
|
|
@@ -18,10 +34,7 @@ export async function runServerCommand(argv = process.argv.slice(2)) {
|
|
|
18
34
|
workspaceRoot: project.outputRoot,
|
|
19
35
|
stateDir: project.outputRoot,
|
|
20
36
|
reviewProject: argv.includes('--review') ? project : null,
|
|
21
|
-
|
|
22
|
-
// panes, dev harnesses) pass it via PORT, and an Atelier that ignores that
|
|
23
|
-
// fights whatever already holds the canonical port instead of coexisting.
|
|
24
|
-
port: args.smoke ? 0 : Number(args.port || process.env.PORT || 8137),
|
|
37
|
+
port: args.smoke ? 0 : resolvePort(args),
|
|
25
38
|
})
|
|
26
39
|
|
|
27
40
|
if (args.smoke) {
|
package/src/upgrade/upgrade.mjs
CHANGED
|
@@ -128,7 +128,7 @@ export function buildAtelierLock({ project, templateId = 'existing-workspace', a
|
|
|
128
128
|
},
|
|
129
129
|
runtime: {
|
|
130
130
|
node: {
|
|
131
|
-
range: packageJson.engines?.node || '>=22.18.0 <23',
|
|
131
|
+
range: packageJson.engines?.node || '>=22.18.0 <23 || >=24.13.1 <25',
|
|
132
132
|
},
|
|
133
133
|
telemetry: 'none',
|
|
134
134
|
egress: 'local-only',
|