@mnstry/atelier 0.2.0-alpha.10 → 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.
Files changed (81) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +681 -39
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +292 -48
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +32 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +124 -17
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +115 -14
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -37
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +240 -51
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +19 -4
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. 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 and nothing
8
- // else. There is no file serving, no command, no evaluation and no route that
9
- // takes a path, a name or code from a request.
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 four, exactly, with no query; the method is 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 random bearer that exists only
26
- // in the owner-only record. A POST body is JSON of at most 1 KiB naming the
27
- // runtime it is meant for, so a request aimed at an earlier runtime on the
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
- if (Object.keys(payload.body).length !== 1 || payload.body.runtimeId !== identity.runtimeId) return send(response, 409, { error: 'request-names-another-runtime' })
99
- if (request.url === '/tick') return send(response, 200, await operations.tick())
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
- const engine = createEngine({ watcherFactory: createFsWatcherFactory(), ...engineOptions, loadProject, dataRoot, adapterFactory, clock, env, platform, lockOwner: { host, port, runtimeId } })
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 { schema: SERVICE_STATUS_SCHEMA, service: { ...identity, status: healthStatus() }, loop: loop.state(), lastTick, lastError, freshness: freshnessSummary(), ...(typeof appStatus === 'function' ? { app: appStatus() } : {}) }
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
- async tick() {
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 it as a
666
- // rejection the CLI can print, instead of an unhandled 'error' event.
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.code === 'EADDRINUSE'
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)
@@ -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
- // argv > PORT env > default. Supervisors that assign a free port (preview
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) {
@@ -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',
@@ -6,7 +6,7 @@
6
6
  "version": "v1",
7
7
  "digest": "sha256:0eff795765e0bbd4c2510acda2e492201c23bc870df6c354eef623484643ab11",
8
8
  "compatibleRootVersions": [
9
- "0.2.0-alpha.10"
9
+ "0.2.0-alpha.12"
10
10
  ],
11
11
  "status": "active"
12
12
  }