@naxodev/apnea 0.2.0 → 0.2.2

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 (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -0,0 +1,452 @@
1
+ import * as crypto from "node:crypto"
2
+ import * as fs from "node:fs"
3
+ import * as os from "node:os"
4
+ import * as path from "node:path"
5
+ import { Effect } from "effect"
6
+ import { ConfigError, OperationLocked } from "../errors.ts"
7
+
8
+ type Owner = {
9
+ readonly pid: number
10
+ readonly token: string
11
+ }
12
+
13
+ const OWNER_FILE = "owner.json"
14
+ const OWNER_LIMIT = 4 * 1024
15
+
16
+ function lockDirectory(): string {
17
+ const identity =
18
+ typeof process.getuid === "function" ? process.getuid() : "user"
19
+ return path.join(os.tmpdir(), `apnea-${identity}`, "operation-locks")
20
+ }
21
+
22
+ function canonicalRepository(root: string): string {
23
+ return fs.realpathSync(root)
24
+ }
25
+
26
+ export function repositoryLockPath(root: string): string {
27
+ const canonical = canonicalRepository(root)
28
+ const key = crypto
29
+ .createHash("sha256")
30
+ .update(`repository\0${canonical}`)
31
+ .digest("hex")
32
+ return path.join(lockDirectory(), `repository-${key}.lock`)
33
+ }
34
+
35
+ export function globalSetupLockPath(accountHome: string): string {
36
+ const canonical = fs.realpathSync(accountHome)
37
+ const key = crypto
38
+ .createHash("sha256")
39
+ .update(`global-setup\0${canonical}`)
40
+ .digest("hex")
41
+ return path.join(lockDirectory(), `global-setup-${key}.lock`)
42
+ }
43
+
44
+ function ensureLockDirectory(directory: string): void {
45
+ for (const component of [path.dirname(directory), directory]) {
46
+ try {
47
+ fs.mkdirSync(component, { mode: 0o700 })
48
+ } catch (error) {
49
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
50
+ }
51
+ const stat = fs.lstatSync(component)
52
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
53
+ throw new ConfigError({
54
+ message: `unsafe Apnea lock directory: ${component}`,
55
+ path: component,
56
+ })
57
+ }
58
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
59
+ throw new ConfigError({
60
+ message: `Apnea lock directory is not owned by the current user: ${component}`,
61
+ path: component,
62
+ })
63
+ }
64
+ fs.chmodSync(component, 0o700)
65
+ }
66
+ }
67
+
68
+ function isCurrentUser(stat: fs.Stats): boolean {
69
+ return typeof process.getuid !== "function" || stat.uid === process.getuid()
70
+ }
71
+
72
+ function hasPrivateMode(stat: fs.Stats): boolean {
73
+ return process.platform === "win32" || (stat.mode & 0o077) === 0
74
+ }
75
+
76
+ function readOwner(lock: string): Owner | null {
77
+ let descriptor: number | undefined
78
+ try {
79
+ const lockStat = fs.lstatSync(lock)
80
+ if (
81
+ lockStat.isSymbolicLink() ||
82
+ !lockStat.isDirectory() ||
83
+ !isCurrentUser(lockStat) ||
84
+ !hasPrivateMode(lockStat)
85
+ ) {
86
+ return null
87
+ }
88
+ const ownerPath = path.join(lock, OWNER_FILE)
89
+ if (fs.lstatSync(ownerPath).isSymbolicLink()) return null
90
+ descriptor = fs.openSync(
91
+ ownerPath,
92
+ process.platform === "win32"
93
+ ? "r"
94
+ : fs.constants.O_RDONLY |
95
+ fs.constants.O_NOFOLLOW |
96
+ fs.constants.O_NONBLOCK,
97
+ )
98
+ const ownerStat = fs.fstatSync(descriptor)
99
+ if (
100
+ !ownerStat.isFile() ||
101
+ !isCurrentUser(ownerStat) ||
102
+ !hasPrivateMode(ownerStat) ||
103
+ ownerStat.size <= 0 ||
104
+ ownerStat.size > OWNER_LIMIT
105
+ ) {
106
+ return null
107
+ }
108
+ const bytes = Buffer.alloc(ownerStat.size)
109
+ let offset = 0
110
+ while (offset < bytes.length) {
111
+ const read = fs.readSync(
112
+ descriptor,
113
+ bytes,
114
+ offset,
115
+ bytes.length - offset,
116
+ offset,
117
+ )
118
+ if (read === 0) return null
119
+ offset += read
120
+ }
121
+ const value = JSON.parse(bytes.toString("utf8")) as unknown
122
+ if (
123
+ typeof value === "object" &&
124
+ value !== null &&
125
+ "pid" in value &&
126
+ typeof value.pid === "number" &&
127
+ Number.isInteger(value.pid) &&
128
+ value.pid > 0 &&
129
+ "token" in value &&
130
+ typeof value.token === "string" &&
131
+ value.token.length > 0
132
+ ) {
133
+ return { pid: value.pid, token: value.token }
134
+ }
135
+ } catch {
136
+ // Malformed ownership is never safe to remove automatically.
137
+ } finally {
138
+ if (descriptor !== undefined) fs.closeSync(descriptor)
139
+ }
140
+ return null
141
+ }
142
+
143
+ function processIsAlive(pid: number): boolean {
144
+ try {
145
+ process.kill(pid, 0)
146
+ return true
147
+ } catch (error) {
148
+ return (error as NodeJS.ErrnoException).code !== "ESRCH"
149
+ }
150
+ }
151
+
152
+ function pathExistsNoFollow(target: string): boolean {
153
+ try {
154
+ fs.lstatSync(target)
155
+ return true
156
+ } catch (error) {
157
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false
158
+ throw error
159
+ }
160
+ }
161
+
162
+ function fsyncDirectory(directory: string): void {
163
+ const descriptor = fs.openSync(directory, fs.constants.O_RDONLY)
164
+ try {
165
+ try {
166
+ fs.fsyncSync(descriptor)
167
+ } catch (error) {
168
+ const code = (error as NodeJS.ErrnoException).code
169
+ const unsupported =
170
+ ["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(code ?? "") ||
171
+ (process.platform === "win32" &&
172
+ ["EISDIR", "EPERM"].includes(code ?? ""))
173
+ if (!unsupported) throw error
174
+ }
175
+ } finally {
176
+ fs.closeSync(descriptor)
177
+ }
178
+ }
179
+
180
+ function writeCandidate(directory: string, owner: Owner): void {
181
+ fs.mkdirSync(directory, { mode: 0o700 })
182
+ const ownerPath = path.join(directory, OWNER_FILE)
183
+ const descriptor = fs.openSync(ownerPath, "wx", 0o600)
184
+ try {
185
+ fs.writeFileSync(descriptor, JSON.stringify(owner), "utf8")
186
+ fs.fsyncSync(descriptor)
187
+ } finally {
188
+ fs.closeSync(descriptor)
189
+ }
190
+ fsyncDirectory(directory)
191
+ }
192
+
193
+ // The caller either releases its own live owner or holds the reclamation guard.
194
+ // A live owner cannot be reclaimed. Stale removers must serialize the token
195
+ // check and rename; checking the token after rename cannot undo displacement.
196
+ function moveOwnedToTombstone(lock: string, token: string): string | null {
197
+ if (readOwner(lock)?.token !== token) return null
198
+ const tombstone = `${lock}.tombstone.${crypto.randomUUID()}`
199
+ try {
200
+ fs.renameSync(lock, tombstone)
201
+ } catch (error) {
202
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
203
+ throw error
204
+ }
205
+ if (readOwner(tombstone)?.token !== token) return null
206
+ return tombstone
207
+ }
208
+
209
+ function removeIfOwned(lock: string, token: string): void {
210
+ const tombstone = moveOwnedToTombstone(lock, token)
211
+ if (tombstone !== null) {
212
+ fs.rmSync(tombstone, { recursive: true, force: true })
213
+ }
214
+ }
215
+
216
+ function acquireLock(
217
+ lock: string,
218
+ resource: string,
219
+ reclaim?: { graceMs: number; now: () => number },
220
+ ): { lock: string; owner: Owner } {
221
+ ensureLockDirectory(path.dirname(lock))
222
+
223
+ for (let attempt = 0; attempt < 3; attempt++) {
224
+ const owner = { pid: process.pid, token: crypto.randomUUID() }
225
+ const candidate = `${lock}.candidate.${owner.token}`
226
+ let contended = false
227
+ try {
228
+ writeCandidate(candidate, owner)
229
+ if (pathExistsNoFollow(lock)) {
230
+ contended = true
231
+ } else {
232
+ try {
233
+ fs.renameSync(candidate, lock)
234
+ } catch (error) {
235
+ if (
236
+ !["EEXIST", "ENOTEMPTY", "EPERM"].includes(
237
+ (error as NodeJS.ErrnoException).code ?? "",
238
+ )
239
+ ) {
240
+ throw error
241
+ }
242
+ contended = true
243
+ }
244
+ }
245
+ } finally {
246
+ fs.rmSync(candidate, { recursive: true, force: true })
247
+ }
248
+
249
+ if (!contended) return { lock, owner }
250
+
251
+ if (!pathExistsNoFollow(lock)) continue
252
+ const existing = readOwner(lock)
253
+ if (existing === null) {
254
+ throw new OperationLocked({
255
+ message:
256
+ `Apnea lock metadata is malformed at ${lock}. ` +
257
+ `Automatic cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`,
258
+ repository: resource,
259
+ lock_path: lock,
260
+ reason: "malformed",
261
+ pid: 0,
262
+ })
263
+ }
264
+ if (processIsAlive(existing.pid)) {
265
+ throw new OperationLocked({
266
+ message: `another Apnea operation holds ${lock} for ${resource} (pid ${existing.pid})`,
267
+ repository: resource,
268
+ lock_path: lock,
269
+ reason: "live",
270
+ pid: existing.pid,
271
+ })
272
+ }
273
+ if (
274
+ reclaim !== undefined &&
275
+ lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs &&
276
+ removeStaleOwner(lock, existing.token, resource)
277
+ ) {
278
+ // Dead owner past the freshness grace: a crashed holder. Reclaiming
279
+ // lets crash-recoverable operations (e.g. a durable commit
280
+ // transaction) resume instead of wedging behind manual cleanup.
281
+ continue
282
+ }
283
+ throw new OperationLocked({
284
+ message:
285
+ `Apnea lock owner pid ${existing.pid} is not live at ${lock}. ` +
286
+ `Automatic stale cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`,
287
+ repository: resource,
288
+ lock_path: lock,
289
+ reason: "stale",
290
+ pid: existing.pid,
291
+ })
292
+ }
293
+
294
+ throw new OperationLocked({
295
+ message: `Apnea lock changed repeatedly at ${lock}; retry the operation`,
296
+ repository: resource,
297
+ lock_path: lock,
298
+ reason: "raced",
299
+ pid: 0,
300
+ })
301
+ }
302
+
303
+ /** Age of the lock directory in ms against the injected clock. */
304
+ function lockAgeMs(lock: string, nowMs: number): number {
305
+ try {
306
+ return Math.max(0, nowMs - fs.lstatSync(lock).mtimeMs)
307
+ } catch {
308
+ return 0
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Only one stale remover may validate ownership and rename the canonical path.
314
+ * Publication needs no guard: the old nonempty directory excludes candidates
315
+ * until rename, and this remover never renames the canonical path again.
316
+ * A delayed remover must acquire the guard and recheck the token, so it cannot
317
+ * displace a replacement. Live-owner release cannot race a matching stale
318
+ * removal because processIsAlive refuses that owner.
319
+ *
320
+ * The guard is deliberately not reclaimable. A crash here requires manual
321
+ * removal of `${lock}.reclaim` after all Apnea processes using this lock stop.
322
+ * Recursively reclaiming a stale guard would reintroduce the same race.
323
+ */
324
+ function removeStaleOwner(
325
+ lock: string,
326
+ token: string,
327
+ resource: string,
328
+ ): boolean {
329
+ const guard = `${lock}.reclaim`
330
+ try {
331
+ fs.mkdirSync(guard, { mode: 0o700 })
332
+ } catch (error) {
333
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
334
+ throw new OperationLocked({
335
+ message:
336
+ `Apnea stale-lock reclamation is guarded at ${guard}. Retry after the other operation completes. ` +
337
+ `If the guard persists, stop all Apnea processes using ${lock}, then remove this guard directory manually: ${guard}`,
338
+ repository: resource,
339
+ lock_path: lock,
340
+ reason: "stale",
341
+ pid: 0,
342
+ })
343
+ }
344
+ try {
345
+ const tombstone = moveOwnedToTombstone(lock, token)
346
+ if (tombstone === null) return false
347
+ fs.rmSync(tombstone, { recursive: true, force: true })
348
+ return true
349
+ } finally {
350
+ fs.rmdirSync(guard)
351
+ }
352
+ }
353
+
354
+ function withLock<A, E, R>(
355
+ lock: string,
356
+ resource: string,
357
+ operation: Effect.Effect<A, E, R>,
358
+ waitForRetry?: Effect.Effect<void>,
359
+ reclaim?: { graceMs: number; now: () => number },
360
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
361
+ const acquireOnce = () =>
362
+ Effect.try({
363
+ try: () => acquireLock(lock, resource, reclaim),
364
+ catch: (error) =>
365
+ error instanceof OperationLocked || error instanceof ConfigError
366
+ ? error
367
+ : new ConfigError({
368
+ message: `could not acquire Apnea operation lock: ${error instanceof Error ? error.message : String(error)}`,
369
+ path: resource,
370
+ }),
371
+ })
372
+ const acquireEffect = (): Effect.Effect<
373
+ { lock: string; owner: Owner },
374
+ OperationLocked | ConfigError
375
+ > =>
376
+ acquireOnce().pipe(
377
+ Effect.catch((error) =>
378
+ error instanceof OperationLocked &&
379
+ error.reason === "live" &&
380
+ waitForRetry
381
+ ? waitForRetry.pipe(Effect.andThen(acquireEffect()))
382
+ : Effect.fail(error),
383
+ ),
384
+ )
385
+ return Effect.acquireUseRelease(
386
+ acquireEffect(),
387
+ () => operation,
388
+ ({ lock: ownedLock, owner }) =>
389
+ Effect.sync(() => removeIfOwned(ownedLock, owner.token)),
390
+ )
391
+ }
392
+
393
+ /** How long a dead owner's lock must sit untouched before reclaim is safe. */
394
+ export const REPOSITORY_LOCK_RECLAIM_GRACE_MS = 60_000
395
+
396
+ export type RepositoryLockOptions = {
397
+ /**
398
+ * Grace period (ms) a stale lock must age past before a dead owner is
399
+ * reclaimed. Mitigates PID reuse: a recycled pid looks alive anyway, and
400
+ * an old lock directory means no live process refreshed it.
401
+ */
402
+ readonly staleGraceMs?: number
403
+ /** Clock seam for tests; defaults to Date.now. */
404
+ readonly now?: () => number
405
+ }
406
+
407
+ export function withRepositoryLock<A, E, R>(
408
+ root: string,
409
+ operation: Effect.Effect<A, E, R>,
410
+ options: RepositoryLockOptions = {},
411
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
412
+ const repository = canonicalRepository(root)
413
+ return withLock(
414
+ repositoryLockPath(repository),
415
+ repository,
416
+ operation,
417
+ undefined,
418
+ {
419
+ graceMs: options.staleGraceMs ?? REPOSITORY_LOCK_RECLAIM_GRACE_MS,
420
+ now: options.now ?? (() => Date.now()),
421
+ },
422
+ )
423
+ }
424
+
425
+ export function withGlobalSetupLock<A, E, R>(
426
+ accountHome: string,
427
+ operation: Effect.Effect<A, E, R>,
428
+ waitForRetry: Effect.Effect<void> = Effect.sleep(25),
429
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
430
+ const home = fs.realpathSync(accountHome)
431
+ return withLock(
432
+ globalSetupLockPath(home),
433
+ `global setup at ${home}`,
434
+ operation,
435
+ waitForRetry,
436
+ )
437
+ }
438
+
439
+ /** Global setup lock is always outermost; repository lock is optional and inner. */
440
+ export function withSetupLocks<A, E, R>(
441
+ accountHome: string,
442
+ root: string,
443
+ lockRepository: boolean,
444
+ operation: Effect.Effect<A, E, R>,
445
+ waitForRetry?: Effect.Effect<void>,
446
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
447
+ return withGlobalSetupLock(
448
+ accountHome,
449
+ lockRepository ? withRepositoryLock(root, operation) : operation,
450
+ waitForRetry,
451
+ )
452
+ }