@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.
- package/CHANGELOG.md +77 -0
- package/README.md +61 -18
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +41 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +171 -0
- package/docs/blocks/claims.md +23 -12
- package/docs/blocks/will-not-do.md +9 -3
- package/docs/design.md +12 -6
- package/docs/install.md +26 -4
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-services.md +101 -0
- package/docs/release-engineering.md +86 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/upgrade.md +40 -2
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/package.json +16 -5
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/src/boundary/content-rules.mjs +283 -20
- package/src/boundary/policy.mjs +162 -72
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/run.mjs +35 -7
- package/src/collaboration/event-ledger.mjs +365 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +2 -1
- package/src/commands/init.mjs +2 -1
- package/src/commands/server.mjs +1 -4
- package/src/commands/sync.mjs +100 -0
- package/src/contracts/corpus.mjs +6 -0
- package/src/disclosure/content-scan.mjs +193 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/graph/graph.mjs +112 -314
- package/src/graph/knowledge-graph.mjs +94 -18
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/project/config.mjs +89 -28
- package/src/project/file-class.mjs +14 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +110 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +788 -0
- package/src/server/local-sidecar.mjs +81 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +3 -2
- package/src/support/feedback-report.mjs +4 -3
- 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
|
+
}
|