@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.6

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 (64) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +61 -18
  3. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  4. package/contracts/public-api-baseline.json +57 -0
  5. package/docs/assurance-controls.md +41 -0
  6. package/docs/atelier-runtime.md +28 -2
  7. package/docs/atelier-sync.md +171 -0
  8. package/docs/blocks/claims.md +23 -12
  9. package/docs/blocks/will-not-do.md +9 -3
  10. package/docs/design.md +12 -6
  11. package/docs/install.md +26 -4
  12. package/docs/knowledge-graph.md +8 -4
  13. package/docs/local-services.md +101 -0
  14. package/docs/release-engineering.md +86 -12
  15. package/docs/repo-boundary-guard.md +12 -2
  16. package/docs/upgrade.md +40 -2
  17. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  18. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  19. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  20. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  21. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  22. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  23. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  24. package/package.json +16 -5
  25. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  26. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  27. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  28. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  29. package/src/boundary/content-rules.mjs +283 -20
  30. package/src/boundary/policy.mjs +162 -72
  31. package/src/cli/execute-command.mjs +36 -0
  32. package/src/cli/run.mjs +35 -7
  33. package/src/collaboration/event-ledger.mjs +365 -0
  34. package/src/collaboration/index.mjs +17 -0
  35. package/src/collaboration/proposals.mjs +265 -65
  36. package/src/commands/attestation.mjs +20 -6
  37. package/src/commands/disclosure.mjs +133 -0
  38. package/src/commands/distribution.mjs +2 -1
  39. package/src/commands/extension-pack.mjs +2 -1
  40. package/src/commands/init.mjs +2 -1
  41. package/src/commands/server.mjs +1 -4
  42. package/src/commands/sync.mjs +100 -0
  43. package/src/contracts/corpus.mjs +6 -0
  44. package/src/disclosure/content-scan.mjs +193 -0
  45. package/src/egress/check.mjs +7 -38
  46. package/src/egress/forbidden-egress.mjs +32 -18
  47. package/src/graph/graph.mjs +112 -314
  48. package/src/graph/knowledge-graph.mjs +94 -18
  49. package/src/harness/context-client.mjs +9 -1
  50. package/src/index.mjs +41 -0
  51. package/src/project/config.mjs +89 -28
  52. package/src/project/file-class.mjs +14 -0
  53. package/src/project/package-root.mjs +10 -0
  54. package/src/project/path-match.mjs +38 -15
  55. package/src/project/private-state.mjs +110 -0
  56. package/src/runtime/git-adapter.mjs +189 -0
  57. package/src/runtime/local-state.mjs +439 -0
  58. package/src/runtime/repository-observation.mjs +491 -0
  59. package/src/runtime/supervisor.mjs +788 -0
  60. package/src/server/local-sidecar.mjs +81 -59
  61. package/src/server/security.mjs +89 -4
  62. package/src/server/server.mjs +3 -2
  63. package/src/support/feedback-report.mjs +4 -3
  64. package/src/upgrade/upgrade.mjs +2 -1
@@ -0,0 +1,439 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import {
5
+ atomicReplacePrivateText,
6
+ ensureContainedPrivateDirectory,
7
+ openRegularFileNoFollow,
8
+ readRegularTextNoFollow,
9
+ } from '../project/private-state.mjs'
10
+ import { runGit } from './git-adapter.mjs'
11
+
12
+ export const ATELIER_RUNTIME_STATE_SCHEMA = 'atelier-runtime-state@v1'
13
+ export const ATELIER_RUNTIME_ENROLLMENT_SCHEMA = 'atelier-runtime-enrollment@v1'
14
+ export const ATELIER_RUNTIME_TRACE_SCHEMA = 'atelier-runtime-operation@v1'
15
+ export const ATELIER_RUNTIME_TRACE_MAX_BYTES = 2 * 1024 * 1024
16
+ export const ATELIER_RUNTIME_TRACE_MAX_RECORDS = 2048
17
+ export const ATELIER_RUNTIME_TRACE_MAX_EVENT_BYTES = 256 * 1024
18
+ export const ATELIER_RUNTIME_PLAN_MAX_AGE_MS = 24 * 60 * 60 * 1000
19
+ export const ATELIER_RUNTIME_PLAN_MAX_FILES = 256
20
+ export const ATELIER_RUNTIME_PLAN_MAX_BYTES = 4 * 1024 * 1024
21
+ export const ATELIER_RUNTIME_STATE_MAX_BYTES = 512 * 1024
22
+ export const ATELIER_RUNTIME_LIVE_OWNER_MAX_AGE_MS = 24 * 60 * 60 * 1000
23
+
24
+ function lstatIfPresent(file) {
25
+ try {
26
+ return fs.lstatSync(file)
27
+ } catch (error) {
28
+ if (error?.code === 'ENOENT') return null
29
+ throw error
30
+ }
31
+ }
32
+
33
+ function runtimeWorkspaceRoot(file) {
34
+ const absolute = path.resolve(file)
35
+ const marker = `${path.sep}.atelier-local${path.sep}`
36
+ const index = absolute.lastIndexOf(marker)
37
+ if (index <= 0) throw new Error('runtime state path is outside .atelier-local')
38
+ return absolute.slice(0, index)
39
+ }
40
+
41
+ function secureRuntimeLeaf(file) {
42
+ const workspaceRoot = runtimeWorkspaceRoot(file)
43
+ const directory = ensureContainedPrivateDirectory({
44
+ workspaceRoot,
45
+ directory: path.dirname(path.resolve(file)),
46
+ label: 'runtime state directory',
47
+ })
48
+ return path.join(directory, path.basename(file))
49
+ }
50
+
51
+ export function atomicWriteJson(file, value) {
52
+ const secured = secureRuntimeLeaf(file)
53
+ atomicReplacePrivateText(secured, `${JSON.stringify(value, null, 2)}\n`)
54
+ }
55
+
56
+ export function readJsonIfPresent(file) {
57
+ const secured = secureRuntimeLeaf(file)
58
+ const stat = lstatIfPresent(secured)
59
+ if (!stat) return null
60
+ if (stat.size > ATELIER_RUNTIME_PLAN_MAX_BYTES) throw new Error('runtime JSON exceeds the resident byte ceiling')
61
+ return JSON.parse(readRegularTextNoFollow(secured))
62
+ }
63
+
64
+ export function removeRuntimeLeaf(file) {
65
+ const secured = secureRuntimeLeaf(file)
66
+ const stat = lstatIfPresent(secured)
67
+ if (!stat) return false
68
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('state leaf is not a regular file')
69
+ fs.unlinkSync(secured)
70
+ return true
71
+ }
72
+
73
+ export function runtimePlanInventory(plansPath) {
74
+ const directory = ensureContainedPrivateDirectory({
75
+ workspaceRoot: runtimeWorkspaceRoot(plansPath),
76
+ directory: plansPath,
77
+ label: 'runtime plans directory',
78
+ })
79
+ const files = []
80
+ let bytes = 0
81
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
82
+ if (!entry.name.endsWith('.json')) continue
83
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error('runtime plans directory contains a redirected or non-file entry')
84
+ const file = path.join(directory, entry.name)
85
+ const stat = fs.lstatSync(file)
86
+ files.push({ file, name: entry.name, bytes: stat.size, mtimeMs: stat.mtimeMs })
87
+ bytes += stat.size
88
+ }
89
+ return { files, bytes }
90
+ }
91
+
92
+ export function runtimePaths(repoRoot) {
93
+ const root = path.join(repoRoot, '.atelier-local', 'runtime')
94
+ return {
95
+ root,
96
+ enrollment: path.join(root, 'enrollment.json'),
97
+ state: path.join(root, 'state.json'),
98
+ control: path.join(root, 'control.json'),
99
+ trace: path.join(root, 'operations.ndjson'),
100
+ plans: path.join(root, 'plans'),
101
+ lock: path.join(root, 'operation.lock'),
102
+ }
103
+ }
104
+
105
+ export function ensureRuntimeStateRoot(repoRoot, gitExecutable) {
106
+ const probe = '.atelier-local/runtime/.ignore-probe'
107
+ const ignored = runGit(gitExecutable, repoRoot, ['check-ignore', '-q', probe], { allowFailure: true })
108
+ if (!ignored.ok) {
109
+ throw new Error('.atelier-local/ must be Git-ignored before Atelier Sync writes machine-local state')
110
+ }
111
+ const root = ensureContainedPrivateDirectory({
112
+ workspaceRoot: repoRoot,
113
+ directory: path.join(repoRoot, '.atelier-local', 'runtime'),
114
+ label: 'runtime state directory',
115
+ })
116
+ const plans = ensureContainedPrivateDirectory({
117
+ workspaceRoot: repoRoot,
118
+ directory: path.join(root, 'plans'),
119
+ label: 'runtime plans directory',
120
+ })
121
+ return { ...runtimePaths(repoRoot), root, plans, lock: path.join(root, 'operation.lock') }
122
+ }
123
+
124
+ function stableJson(value) {
125
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
126
+ if (!value || typeof value !== 'object') return JSON.stringify(value)
127
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`
128
+ }
129
+
130
+ function digest(value) {
131
+ return crypto.createHash('sha256').update(stableJson(value)).digest('hex')
132
+ }
133
+
134
+ export function readOperationTrace(tracePath) {
135
+ const secured = secureRuntimeLeaf(tracePath)
136
+ const stat = lstatIfPresent(secured)
137
+ if (!stat) return []
138
+ if (stat.size > ATELIER_RUNTIME_TRACE_MAX_BYTES) throw new Error('runtime trace exceeds the resident byte ceiling')
139
+ const records = readRegularTextNoFollow(secured).split('\n').filter(Boolean).map((line, index) => {
140
+ try {
141
+ return JSON.parse(line)
142
+ } catch (error) {
143
+ throw new Error(`invalid runtime trace line ${index + 1}: ${error.message}`)
144
+ }
145
+ })
146
+ if (records.length > ATELIER_RUNTIME_TRACE_MAX_RECORDS) throw new Error('runtime trace exceeds the resident record ceiling')
147
+ let previousHash = null
148
+ records.forEach((record, index) => {
149
+ const { hash, ...unsigned } = record
150
+ if (record.sequence !== index + 1) throw new Error(`runtime trace sequence gap at line ${index + 1}`)
151
+ if ((record.previousHash ?? null) !== previousHash) throw new Error(`runtime trace chain mismatch at line ${index + 1}`)
152
+ if (digest(unsigned) !== hash) throw new Error(`runtime trace hash mismatch at line ${index + 1}`)
153
+ previousHash = hash
154
+ })
155
+ return records
156
+ }
157
+
158
+ function boundedTraceEvent(event) {
159
+ const encoded = JSON.stringify(event)
160
+ const originalBytes = Buffer.byteLength(encoded)
161
+ if (originalBytes <= ATELIER_RUNTIME_TRACE_MAX_EVENT_BYTES) return event
162
+ return {
163
+ at: event?.at,
164
+ operation: event?.operation || 'trace-event',
165
+ outcome: event?.outcome || 'recorded',
166
+ ...(event?.operationId ? { operationId: String(event.operationId) } : {}),
167
+ ...(event?.mode ? { mode: String(event.mode) } : {}),
168
+ details: {
169
+ truncated: true,
170
+ originalBytes,
171
+ originalSha256: crypto.createHash('sha256').update(encoded).digest('hex'),
172
+ },
173
+ }
174
+ }
175
+
176
+ function traceRecoveryCheckpoint(secured, event, reason) {
177
+ const stat = lstatIfPresent(secured)
178
+ let priorSha256 = null
179
+ try {
180
+ if ((stat?.size ?? 0) <= ATELIER_RUNTIME_TRACE_MAX_BYTES) {
181
+ priorSha256 = crypto.createHash('sha256').update(readRegularTextNoFollow(secured)).digest('hex')
182
+ }
183
+ } catch {
184
+ // The recovery checkpoint still records the size and reason if the old
185
+ // regular file cannot be read completely.
186
+ }
187
+ const checkpointUnsigned = {
188
+ schema: ATELIER_RUNTIME_TRACE_SCHEMA,
189
+ sequence: 1,
190
+ previousHash: null,
191
+ at: event.at,
192
+ operation: 'trace-checkpoint',
193
+ outcome: 'recovered',
194
+ details: { reason, priorBytes: stat?.size ?? 0, priorSha256 },
195
+ }
196
+ const checkpoint = { ...checkpointUnsigned, hash: digest(checkpointUnsigned) }
197
+ atomicReplacePrivateText(secured, `${JSON.stringify(checkpoint)}\n`)
198
+ return checkpoint
199
+ }
200
+
201
+ export function appendOperationTrace(tracePath, event) {
202
+ const secured = secureRuntimeLeaf(tracePath)
203
+ const boundedEvent = boundedTraceEvent(event)
204
+ let records
205
+ try {
206
+ records = readOperationTrace(secured)
207
+ } catch (error) {
208
+ records = [traceRecoveryCheckpoint(secured, boundedEvent, error.message)]
209
+ }
210
+ let previousHash = records.at(-1)?.hash ?? null
211
+ const nextProbe = JSON.stringify({ schema: ATELIER_RUNTIME_TRACE_SCHEMA, sequence: records.length + 1, previousHash, ...boundedEvent, hash: '0'.repeat(64) })
212
+ const currentBytes = lstatIfPresent(secured)?.size ?? 0
213
+ if (records.length >= ATELIER_RUNTIME_TRACE_MAX_RECORDS || currentBytes + Buffer.byteLength(`${nextProbe}\n`) > ATELIER_RUNTIME_TRACE_MAX_BYTES) {
214
+ const checkpointUnsigned = {
215
+ schema: ATELIER_RUNTIME_TRACE_SCHEMA,
216
+ sequence: 1,
217
+ previousHash: null,
218
+ at: boundedEvent.at,
219
+ operation: 'trace-checkpoint',
220
+ outcome: 'compacted',
221
+ details: { compactedRecords: records.length, priorLastHash: previousHash },
222
+ }
223
+ const checkpoint = { ...checkpointUnsigned, hash: digest(checkpointUnsigned) }
224
+ atomicReplacePrivateText(secured, `${JSON.stringify(checkpoint)}\n`)
225
+ records = [checkpoint]
226
+ previousHash = checkpoint.hash
227
+ }
228
+ const unsigned = {
229
+ schema: ATELIER_RUNTIME_TRACE_SCHEMA,
230
+ sequence: records.length + 1,
231
+ previousHash,
232
+ ...boundedEvent,
233
+ }
234
+ const record = { ...unsigned, hash: digest(unsigned) }
235
+ const descriptor = openRegularFileNoFollow(
236
+ secured,
237
+ fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT,
238
+ 0o600,
239
+ )
240
+ try {
241
+ fs.writeFileSync(descriptor, `${JSON.stringify(record)}\n`)
242
+ fs.fsyncSync(descriptor)
243
+ } finally {
244
+ fs.closeSync(descriptor)
245
+ }
246
+ try {
247
+ fs.chmodSync(secured, 0o600)
248
+ } catch {
249
+ // Windows inherits the current user's ACL; POSIX mode is best effort.
250
+ }
251
+ return record
252
+ }
253
+
254
+ function processAlive(pid) {
255
+ if (!Number.isInteger(pid) || pid <= 0) return false
256
+ try {
257
+ process.kill(pid, 0)
258
+ return true
259
+ } catch (error) {
260
+ return error?.code === 'EPERM'
261
+ }
262
+ }
263
+
264
+ function removeOwnedLock(lockPath, lockNonce) {
265
+ const stat = lstatIfPresent(lockPath)
266
+ if (!stat) return
267
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('runtime lock is redirected or not a directory')
268
+ const ownerFile = path.join(lockPath, 'owner.json')
269
+ const owner = readJsonIfPresent(ownerFile)
270
+ if (!lockNonce || owner?.lockNonce !== lockNonce) throw new Error('runtime lock ownership changed before release')
271
+ const quarantine = `${lockPath}.release-${lockNonce}`
272
+ fs.renameSync(lockPath, quarantine)
273
+ fs.rmSync(quarantine, { recursive: true, force: true })
274
+ }
275
+
276
+ export function acquireRepositoryLock(paths, { operation, clock = () => new Date().toISOString(), ownerWriteGraceMs = 30_000 } = {}) {
277
+ ensureContainedPrivateDirectory({ workspaceRoot: runtimeWorkspaceRoot(paths.root), directory: paths.root, label: 'runtime state directory' })
278
+ const lockNonce = crypto.randomBytes(16).toString('hex')
279
+ try {
280
+ fs.mkdirSync(paths.lock, { mode: 0o700 })
281
+ } catch (error) {
282
+ if (error.code !== 'EEXIST') throw error
283
+ const lockStat = lstatIfPresent(paths.lock)
284
+ if (!lockStat || lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
285
+ throw new Error('runtime lock is redirected or not a directory')
286
+ }
287
+ let owner = null
288
+ try {
289
+ owner = readJsonIfPresent(path.join(paths.lock, 'owner.json'))
290
+ } catch (ownerError) {
291
+ if (/redirected|regular file|escapes workspace/.test(ownerError.message)) throw ownerError
292
+ // A truncated owner file is not authority to delete a possibly live lock.
293
+ }
294
+ const ownerTimestamp = Date.parse(owner?.acquiredAt || '')
295
+ const ownerAgeMs = Date.now() - (Number.isFinite(ownerTimestamp) ? ownerTimestamp : lockStat.mtimeMs)
296
+ if (owner && processAlive(owner.pid) && ownerAgeMs < ATELIER_RUNTIME_LIVE_OWNER_MAX_AGE_MS) {
297
+ return { ok: false, code: 'repository-busy', owner }
298
+ }
299
+ const currentLockStat = lstatIfPresent(paths.lock)
300
+ if (!currentLockStat || currentLockStat.isSymbolicLink() || !currentLockStat.isDirectory()) {
301
+ throw new Error('runtime lock is redirected or not a directory')
302
+ }
303
+ const ageMs = Date.now() - currentLockStat.mtimeMs
304
+ if (ageMs < ownerWriteGraceMs) return { ok: false, code: 'repository-busy', owner: owner || { operation: 'lock-owner-pending', ageMs } }
305
+ const claimPath = path.join(paths.lock, 'recovery.claim')
306
+ let claimDescriptor
307
+ try {
308
+ claimDescriptor = openRegularFileNoFollow(
309
+ claimPath,
310
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
311
+ 0o600,
312
+ )
313
+ fs.writeFileSync(claimDescriptor, lockNonce)
314
+ fs.fsyncSync(claimDescriptor)
315
+ } catch (claimError) {
316
+ if (claimError?.code === 'EEXIST') {
317
+ const claimStat = lstatIfPresent(claimPath)
318
+ if (!claimStat || claimStat.isSymbolicLink() || !claimStat.isFile()) throw new Error('runtime recovery claim is redirected or not a regular file')
319
+ const claimAgeMs = Date.now() - claimStat.mtimeMs
320
+ if (claimAgeMs < ownerWriteGraceMs) {
321
+ return { ok: false, code: 'repository-busy', owner: owner || { operation: 'stale-lock-recovery', ageMs: claimAgeMs } }
322
+ }
323
+ const staleClaim = `${claimPath}.stale-${lockNonce}`
324
+ try {
325
+ fs.renameSync(claimPath, staleClaim)
326
+ const movedClaimStat = lstatIfPresent(staleClaim)
327
+ if (!movedClaimStat || movedClaimStat.dev !== claimStat.dev || movedClaimStat.ino !== claimStat.ino) {
328
+ if (movedClaimStat && !lstatIfPresent(claimPath)) fs.renameSync(staleClaim, claimPath)
329
+ return { ok: false, code: 'repository-busy', owner: owner || { operation: 'stale-lock-recovery-identity-changed' } }
330
+ }
331
+ fs.unlinkSync(staleClaim)
332
+ claimDescriptor = openRegularFileNoFollow(
333
+ claimPath,
334
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
335
+ 0o600,
336
+ )
337
+ fs.writeFileSync(claimDescriptor, lockNonce)
338
+ fs.fsyncSync(claimDescriptor)
339
+ } catch (retryError) {
340
+ if (retryError?.code === 'EEXIST' || retryError?.code === 'ENOENT') {
341
+ return { ok: false, code: 'repository-busy', owner: owner || { operation: 'stale-lock-recovery-lost' } }
342
+ }
343
+ throw retryError
344
+ }
345
+ } else if (claimError?.code === 'ENOENT') {
346
+ return { ok: false, code: 'repository-busy', owner: owner || { operation: 'stale-lock-recovery' } }
347
+ } else {
348
+ throw claimError
349
+ }
350
+ } finally {
351
+ if (claimDescriptor != null) fs.closeSync(claimDescriptor)
352
+ }
353
+ let currentOwner = null
354
+ try {
355
+ currentOwner = readJsonIfPresent(path.join(paths.lock, 'owner.json'))
356
+ } catch (ownerError) {
357
+ if (/redirected|regular file|escapes workspace/.test(ownerError.message)) throw ownerError
358
+ // The exclusive recovery claim and age gate permit quarantining a stale,
359
+ // corrupt owner record; corruption is never authority to touch a new lock.
360
+ }
361
+ const currentOwnerTimestamp = Date.parse(currentOwner?.acquiredAt || '')
362
+ const currentOwnerAgeMs = Date.now() - (Number.isFinite(currentOwnerTimestamp) ? currentOwnerTimestamp : lockStat.mtimeMs)
363
+ if (currentOwner && processAlive(currentOwner.pid) && currentOwnerAgeMs < ATELIER_RUNTIME_LIVE_OWNER_MAX_AGE_MS) {
364
+ try {
365
+ if (readRegularTextNoFollow(claimPath) === lockNonce) fs.unlinkSync(claimPath)
366
+ } catch {
367
+ // The current owner controls the lock; leave it untouched on uncertainty.
368
+ }
369
+ return { ok: false, code: 'repository-busy', owner: currentOwner }
370
+ }
371
+ const quarantine = `${paths.lock}.stale-${lockNonce}`
372
+ try {
373
+ const beforeRecovery = lstatIfPresent(paths.lock)
374
+ if (!beforeRecovery || beforeRecovery.dev !== lockStat.dev || beforeRecovery.ino !== lockStat.ino) {
375
+ return { ok: false, code: 'repository-busy', owner: { operation: 'stale-lock-recovery-identity-changed' } }
376
+ }
377
+ fs.renameSync(paths.lock, quarantine)
378
+ const movedLock = lstatIfPresent(quarantine)
379
+ if (!movedLock || movedLock.dev !== lockStat.dev || movedLock.ino !== lockStat.ino) {
380
+ if (movedLock && !lstatIfPresent(paths.lock)) fs.renameSync(quarantine, paths.lock)
381
+ return { ok: false, code: 'repository-busy', owner: { operation: 'stale-lock-recovery-identity-changed' } }
382
+ }
383
+ fs.mkdirSync(paths.lock, { mode: 0o700 })
384
+ } catch (recoveryError) {
385
+ if (recoveryError?.code === 'EEXIST' || recoveryError?.code === 'ENOENT') {
386
+ if (lstatIfPresent(quarantine)) fs.rmSync(quarantine, { recursive: true, force: true })
387
+ return { ok: false, code: 'repository-busy', owner: { operation: 'stale-lock-recovery-lost' } }
388
+ }
389
+ throw recoveryError
390
+ }
391
+ fs.rmSync(quarantine, { recursive: true, force: true })
392
+ }
393
+ const owner = { lockNonce, pid: process.pid, operation, acquiredAt: clock() }
394
+ atomicWriteJson(path.join(paths.lock, 'owner.json'), owner)
395
+ return {
396
+ ok: true,
397
+ owner,
398
+ release() {
399
+ removeOwnedLock(paths.lock, lockNonce)
400
+ },
401
+ }
402
+ }
403
+
404
+ export function writeRuntimeState(paths, state) {
405
+ let value = { schema: ATELIER_RUNTIME_STATE_SCHEMA, ...state }
406
+ const encodedBytes = Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`)
407
+ if (encodedBytes > ATELIER_RUNTIME_STATE_MAX_BYTES) {
408
+ value = {
409
+ schema: ATELIER_RUNTIME_STATE_SCHEMA,
410
+ status: 'attention',
411
+ code: 'runtime-state-resident-ceiling',
412
+ message: 'runtime state exceeded its resident byte ceiling and was compacted',
413
+ incidentId: state.incidentId ?? null,
414
+ updatedAt: state.updatedAt ?? new Date().toISOString(),
415
+ observation: state.observation ? {
416
+ sourceSchema: state.observation.sourceSchema ?? state.observation.schema,
417
+ observedAt: state.observation.observedAt,
418
+ complete: false,
419
+ root: state.observation.root,
420
+ branch: state.observation.branch ? { branch: state.observation.branch.branch, head: state.observation.branch.head } : null,
421
+ status: state.observation.status ? { clean: state.observation.status.clean, digest: state.observation.status.digest } : null,
422
+ blockers: [{ code: 'runtime-state-resident-ceiling', message: 'full resident state was too large to retain', details: { limitBytes: ATELIER_RUNTIME_STATE_MAX_BYTES, observedBytes: encodedBytes } }],
423
+ } : null,
424
+ details: { limitBytes: ATELIER_RUNTIME_STATE_MAX_BYTES, observedBytes: encodedBytes },
425
+ }
426
+ }
427
+ atomicWriteJson(paths.state, value)
428
+ return value
429
+ }
430
+
431
+ export function readRuntimeControl(paths) {
432
+ return readJsonIfPresent(paths.control) || { paused: false, reason: null, updatedAt: null }
433
+ }
434
+
435
+ export function writeRuntimeControl(paths, { paused, reason = null, updatedAt = new Date().toISOString() }) {
436
+ const control = { paused: Boolean(paused), reason: paused ? String(reason || 'paused by user') : null, updatedAt }
437
+ atomicWriteJson(paths.control, control)
438
+ return control
439
+ }