@naxodev/apnea 0.2.0 → 0.2.1

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 +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8283 -15058
  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 +389 -242
  32. package/extension/services/operation-lock.ts +418 -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 +274 -57
  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,418 @@
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
+ function moveOwnedToTombstone(lock: string, token: string): string | null {
194
+ if (readOwner(lock)?.token !== token) return null
195
+ const tombstone = `${lock}.tombstone.${crypto.randomUUID()}`
196
+ try {
197
+ fs.renameSync(lock, tombstone)
198
+ } catch (error) {
199
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
200
+ throw error
201
+ }
202
+ if (readOwner(tombstone)?.token !== token) return null
203
+ return tombstone
204
+ }
205
+
206
+ function removeIfOwned(lock: string, token: string): void {
207
+ const tombstone = moveOwnedToTombstone(lock, token)
208
+ if (tombstone !== null) {
209
+ fs.rmSync(tombstone, { recursive: true, force: true })
210
+ }
211
+ }
212
+
213
+ function acquireLock(
214
+ lock: string,
215
+ resource: string,
216
+ reclaim?: { graceMs: number; now: () => number },
217
+ ): { lock: string; owner: Owner } {
218
+ ensureLockDirectory(path.dirname(lock))
219
+
220
+ for (let attempt = 0; attempt < 3; attempt++) {
221
+ const owner = { pid: process.pid, token: crypto.randomUUID() }
222
+ const candidate = `${lock}.candidate.${owner.token}`
223
+ let contended = false
224
+ try {
225
+ writeCandidate(candidate, owner)
226
+ if (pathExistsNoFollow(lock)) {
227
+ contended = true
228
+ } else {
229
+ try {
230
+ fs.renameSync(candidate, lock)
231
+ } catch (error) {
232
+ if (
233
+ !["EEXIST", "ENOTEMPTY", "EPERM"].includes(
234
+ (error as NodeJS.ErrnoException).code ?? "",
235
+ )
236
+ ) {
237
+ throw error
238
+ }
239
+ contended = true
240
+ }
241
+ }
242
+ } finally {
243
+ fs.rmSync(candidate, { recursive: true, force: true })
244
+ }
245
+
246
+ if (!contended) return { lock, owner }
247
+
248
+ if (!pathExistsNoFollow(lock)) continue
249
+ const existing = readOwner(lock)
250
+ if (existing === null) {
251
+ throw new OperationLocked({
252
+ message:
253
+ `Apnea lock metadata is malformed at ${lock}. ` +
254
+ `Automatic cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`,
255
+ repository: resource,
256
+ lock_path: lock,
257
+ reason: "malformed",
258
+ pid: 0,
259
+ })
260
+ }
261
+ if (processIsAlive(existing.pid)) {
262
+ throw new OperationLocked({
263
+ message: `another Apnea operation holds ${lock} for ${resource} (pid ${existing.pid})`,
264
+ repository: resource,
265
+ lock_path: lock,
266
+ reason: "live",
267
+ pid: existing.pid,
268
+ })
269
+ }
270
+ if (
271
+ reclaim !== undefined &&
272
+ lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs &&
273
+ removeStaleOwner(lock, existing.token)
274
+ ) {
275
+ // Dead owner past the freshness grace: a crashed holder. Reclaiming
276
+ // lets crash-recoverable operations (e.g. a durable commit
277
+ // transaction) resume instead of wedging behind manual cleanup.
278
+ continue
279
+ }
280
+ throw new OperationLocked({
281
+ message:
282
+ `Apnea lock owner pid ${existing.pid} is not live at ${lock}. ` +
283
+ `Automatic stale cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`,
284
+ repository: resource,
285
+ lock_path: lock,
286
+ reason: "stale",
287
+ pid: existing.pid,
288
+ })
289
+ }
290
+
291
+ throw new OperationLocked({
292
+ message: `Apnea lock changed repeatedly at ${lock}; retry the operation`,
293
+ repository: resource,
294
+ lock_path: lock,
295
+ reason: "raced",
296
+ pid: 0,
297
+ })
298
+ }
299
+
300
+ /** Age of the lock directory in ms against the injected clock. */
301
+ function lockAgeMs(lock: string, nowMs: number): number {
302
+ try {
303
+ return Math.max(0, nowMs - fs.lstatSync(lock).mtimeMs)
304
+ } catch {
305
+ return 0
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Remove a validated stale owner atomically: the tombstone rename re-checks
311
+ * the token, so a concurrently refreshed lock is never displaced.
312
+ */
313
+ function removeStaleOwner(lock: string, token: string): boolean {
314
+ const tombstone = moveOwnedToTombstone(lock, token)
315
+ if (tombstone === null) return false
316
+ fs.rmSync(tombstone, { recursive: true, force: true })
317
+ return true
318
+ }
319
+
320
+ function withLock<A, E, R>(
321
+ lock: string,
322
+ resource: string,
323
+ operation: Effect.Effect<A, E, R>,
324
+ waitForRetry?: Effect.Effect<void>,
325
+ reclaim?: { graceMs: number; now: () => number },
326
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
327
+ const acquireOnce = () =>
328
+ Effect.try({
329
+ try: () => acquireLock(lock, resource, reclaim),
330
+ catch: (error) =>
331
+ error instanceof OperationLocked || error instanceof ConfigError
332
+ ? error
333
+ : new ConfigError({
334
+ message: `could not acquire Apnea operation lock: ${error instanceof Error ? error.message : String(error)}`,
335
+ path: resource,
336
+ }),
337
+ })
338
+ const acquireEffect = (): Effect.Effect<
339
+ { lock: string; owner: Owner },
340
+ OperationLocked | ConfigError
341
+ > =>
342
+ acquireOnce().pipe(
343
+ Effect.catch((error) =>
344
+ error instanceof OperationLocked &&
345
+ error.reason === "live" &&
346
+ waitForRetry
347
+ ? waitForRetry.pipe(Effect.andThen(acquireEffect()))
348
+ : Effect.fail(error),
349
+ ),
350
+ )
351
+ return Effect.acquireUseRelease(
352
+ acquireEffect(),
353
+ () => operation,
354
+ ({ lock: ownedLock, owner }) =>
355
+ Effect.sync(() => removeIfOwned(ownedLock, owner.token)),
356
+ )
357
+ }
358
+
359
+ /** How long a dead owner's lock must sit untouched before reclaim is safe. */
360
+ export const REPOSITORY_LOCK_RECLAIM_GRACE_MS = 60_000
361
+
362
+ export type RepositoryLockOptions = {
363
+ /**
364
+ * Grace period (ms) a stale lock must age past before a dead owner is
365
+ * reclaimed. Mitigates PID reuse: a recycled pid looks alive anyway, and
366
+ * an old lock directory means no live process refreshed it.
367
+ */
368
+ readonly staleGraceMs?: number
369
+ /** Clock seam for tests; defaults to Date.now. */
370
+ readonly now?: () => number
371
+ }
372
+
373
+ export function withRepositoryLock<A, E, R>(
374
+ root: string,
375
+ operation: Effect.Effect<A, E, R>,
376
+ options: RepositoryLockOptions = {},
377
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
378
+ const repository = canonicalRepository(root)
379
+ return withLock(
380
+ repositoryLockPath(repository),
381
+ repository,
382
+ operation,
383
+ undefined,
384
+ {
385
+ graceMs: options.staleGraceMs ?? REPOSITORY_LOCK_RECLAIM_GRACE_MS,
386
+ now: options.now ?? (() => Date.now()),
387
+ },
388
+ )
389
+ }
390
+
391
+ export function withGlobalSetupLock<A, E, R>(
392
+ accountHome: string,
393
+ operation: Effect.Effect<A, E, R>,
394
+ waitForRetry: Effect.Effect<void> = Effect.sleep(25),
395
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
396
+ const home = fs.realpathSync(accountHome)
397
+ return withLock(
398
+ globalSetupLockPath(home),
399
+ `global setup at ${home}`,
400
+ operation,
401
+ waitForRetry,
402
+ )
403
+ }
404
+
405
+ /** Global setup lock is always outermost; repository lock is optional and inner. */
406
+ export function withSetupLocks<A, E, R>(
407
+ accountHome: string,
408
+ root: string,
409
+ lockRepository: boolean,
410
+ operation: Effect.Effect<A, E, R>,
411
+ waitForRetry?: Effect.Effect<void>,
412
+ ): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
413
+ return withGlobalSetupLock(
414
+ accountHome,
415
+ lockRepository ? withRepositoryLock(root, operation) : operation,
416
+ waitForRetry,
417
+ )
418
+ }