@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
@@ -1,37 +1,81 @@
1
- import { spawn, spawnSync, type ChildProcess } from "node:child_process"
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
1
+ import { spawnSync } from "node:child_process"
2
+ import {
3
+ closeSync,
4
+ constants as fsConstants,
5
+ fstatSync,
6
+ lstatSync,
7
+ mkdtempSync,
8
+ openSync,
9
+ readdirSync,
10
+ readSync,
11
+ readlinkSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs"
15
+ import { createHash, randomUUID } from "node:crypto"
3
16
  import { tmpdir } from "node:os"
4
17
  import * as path from "node:path"
5
- import { Context, Effect, Layer } from "effect"
18
+ import { Clock, Context, Effect, Layer, Result } from "effect"
6
19
  import {
7
20
  formatVerifyBlock,
8
21
  normalizeVerifySource,
9
22
  type VerifyBlock,
10
23
  } from "../domain/verify-commands.ts"
11
24
  import { VcsError } from "../errors.ts"
12
- import type { VcsBackend } from "../domain/types.ts"
25
+ import type {
26
+ GitPendingCommit,
27
+ JjPendingCommit,
28
+ PendingCommit,
29
+ VcsBackend,
30
+ } from "../domain/types.ts"
13
31
  import { FileSystem } from "./file-system.ts"
32
+ import {
33
+ Process,
34
+ ProcessExitError,
35
+ ProcessOutputError,
36
+ ProcessTimeoutError,
37
+ type ProcessService,
38
+ } from "./process.ts"
14
39
 
15
40
  export interface VcsService {
16
41
  readonly detect: (root: string) => Effect.Effect<VcsBackend | null>
17
- readonly isDirty: (root: string, vcs: VcsBackend) => Effect.Effect<boolean>
42
+ readonly isDirty: (
43
+ root: string,
44
+ vcs: VcsBackend,
45
+ ) => Effect.Effect<boolean, VcsError>
18
46
  readonly treeFingerprint: (
19
47
  root: string,
20
48
  vcs: VcsBackend,
21
- ) => Effect.Effect<string>
49
+ ) => Effect.Effect<string, VcsError>
22
50
  readonly ensureGitBranch: (
23
51
  root: string,
24
52
  slug: string,
25
53
  ) => Effect.Effect<string, VcsError>
26
- readonly commitPhase: (
54
+ /**
55
+ * Prepare a commit transaction without moving any ref. Git stages the tree
56
+ * in an isolated index; jj describes `@`. The returned anchor is everything
57
+ * the workflow must persist as `pending_commit` before calling
58
+ * `completeCommit`.
59
+ */
60
+ readonly prepareCommit: (
27
61
  root: string,
28
62
  vcs: VcsBackend,
29
63
  message: string,
64
+ ) => Effect.Effect<PreparedCommit, VcsError>
65
+ /**
66
+ * Complete (or recognize an already-completed) prepared commit exactly
67
+ * once, returning the committed change/commit id. Drift between the
68
+ * persisted anchor and the repository is refused with a typed error.
69
+ */
70
+ readonly completeCommit: (
71
+ root: string,
72
+ vcs: VcsBackend,
73
+ pending: PendingCommit,
30
74
  ) => Effect.Effect<string, VcsError>
31
75
  readonly setBookmarkAtTerminus: (
32
76
  root: string,
33
77
  slug: string,
34
- ) => Effect.Effect<void>
78
+ ) => Effect.Effect<void, VcsError>
35
79
  readonly runVerify: (
36
80
  root: string,
37
81
  blocks: readonly VerifyBlock[],
@@ -41,24 +85,461 @@ export interface VcsService {
41
85
 
42
86
  export class Vcs extends Context.Service<Vcs, VcsService>()("apnea/Vcs") {}
43
87
 
88
+ /**
89
+ * Backend-specific result of `prepareCommit`: the common transaction fields
90
+ * plus the anchor fields of the corresponding `PendingCommit` member.
91
+ */
92
+ export type PreparedCommit =
93
+ | Omit<GitPendingCommit, "phase_index" | "no_remaining_phases" | "verify_log">
94
+ | Omit<JjPendingCommit, "phase_index" | "no_remaining_phases" | "verify_log">
95
+
96
+ /** The trailer line appended to every prepared commit message body. */
97
+ export const TRANSACTION_TRAILER_PREFIX = "Apnea-Transaction:"
98
+
99
+ export function withTransactionTrailer(message: string, id: string): string {
100
+ return `${message}\n\n${TRANSACTION_TRAILER_PREFIX} ${id}`
101
+ }
102
+
103
+ /** Strip the trailer for comparing a retry's `message` param. */
104
+ export function withoutTransactionTrailer(message: string): string {
105
+ const index = message.lastIndexOf(`\n\n${TRANSACTION_TRAILER_PREFIX} `)
106
+ return index === -1 ? message : message.slice(0, index)
107
+ }
108
+
109
+ function isUuid(value: string): boolean {
110
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
111
+ value,
112
+ )
113
+ }
114
+
44
115
  function run(
45
116
  cmd: string,
46
117
  args: string[],
47
118
  cwd: string,
119
+ env?: NodeJS.ProcessEnv,
48
120
  ): { ok: boolean; stdout: string; stderr: string; code: number } {
49
121
  const r = spawnSync(cmd, args, {
50
122
  cwd,
51
123
  encoding: "utf8",
52
124
  maxBuffer: 10 * 1024 * 1024,
125
+ env: env === undefined ? undefined : { ...process.env, ...env },
53
126
  })
54
127
  return {
55
128
  ok: r.status === 0,
56
129
  stdout: (r.stdout ?? "").toString(),
57
- stderr: (r.stderr ?? "").toString(),
130
+ stderr: (r.stderr ?? r.error?.message ?? "").toString(),
58
131
  code: r.status ?? 1,
59
132
  }
60
133
  }
61
134
 
135
+ function runRaw(
136
+ cmd: string,
137
+ args: string[],
138
+ cwd: string,
139
+ env?: NodeJS.ProcessEnv,
140
+ ): { ok: boolean; stdout: Buffer; stderr: string; code: number } {
141
+ const result = spawnSync(cmd, args, {
142
+ cwd,
143
+ encoding: null,
144
+ maxBuffer: 10 * 1024 * 1024,
145
+ env: env === undefined ? undefined : { ...process.env, ...env },
146
+ })
147
+ return {
148
+ ok: result.status === 0,
149
+ stdout: result.stdout ?? Buffer.alloc(0),
150
+ stderr: (result.stderr ?? result.error?.message ?? "").toString("utf8"),
151
+ code: result.status ?? 1,
152
+ }
153
+ }
154
+
155
+ type CommandResult = ReturnType<typeof run>
156
+ export type VcsCommandRunner = typeof run
157
+ export type VcsRawCommandRunner = typeof runRaw
158
+
159
+ /**
160
+ * Runner for repository-mutating VCS commands. Unlike the synchronous
161
+ * `VcsCommandRunner` (bounded reads over spawnSync), mutations go through
162
+ * the #107 Process service so they carry a hard timeout, kill their process
163
+ * tree on cancellation, and surface typed failures.
164
+ */
165
+ export type VcsMutationRunner = (
166
+ command: string,
167
+ args: string[],
168
+ cwd: string,
169
+ env?: NodeJS.ProcessEnv,
170
+ ) => Effect.Effect<CommandResult, VcsError>
171
+
172
+ /** Upper bound for a single mutating VCS command (commit-tree, describe, …). */
173
+ export const MUTATING_VCS_TIMEOUT_MS = 120_000
174
+
175
+ export function processMutationRunner(
176
+ processService: ProcessService,
177
+ ): VcsMutationRunner {
178
+ return (command, args, cwd, env) =>
179
+ processService
180
+ .run({
181
+ command,
182
+ args,
183
+ cwd,
184
+ env: env === undefined ? undefined : { ...process.env, ...env },
185
+ timeoutMs: MUTATING_VCS_TIMEOUT_MS,
186
+ })
187
+ .pipe(
188
+ Effect.map((result) => ({
189
+ ok: result.exitCode === 0,
190
+ stdout: result.stdout,
191
+ stderr: result.stderr,
192
+ code: result.exitCode,
193
+ })),
194
+ Effect.mapError(
195
+ (error): VcsError =>
196
+ new VcsError({
197
+ message: `${command} failed: ${error.message}`,
198
+ command: `${command} ${args[0] ?? ""}`.trim(),
199
+ }),
200
+ ),
201
+ )
202
+ }
203
+
204
+ /** Test seam: lift a synchronous runner into the mutation-runner shape. */
205
+ export function syncMutationRunner(
206
+ runCommand: VcsCommandRunner,
207
+ ): VcsMutationRunner {
208
+ return (command, args, cwd, env) =>
209
+ Effect.sync(() => runCommand(command, args, cwd, env))
210
+ }
211
+
212
+ const APNEA_ICASE_PATHSPEC = ":(icase).apnea"
213
+ const APNEA_ICASE_EXCLUDES = [
214
+ ":(exclude,icase).apnea",
215
+ ":(exclude,icase).apnea/**",
216
+ ]
217
+ const JJ_APNEA_ICASE = "root-prefix-glob-i:.apnea"
218
+ const JJ_NOT_APNEA_ICASE = `~${JJ_APNEA_ICASE}`
219
+ export const UNTRACKED_FINGERPRINT_MAX_BYTES = 256 * 1024 * 1024
220
+ export const UNTRACKED_FINGERPRINT_TIMEOUT_MS = 10_000
221
+
222
+ function requireCommand(
223
+ result: CommandResult,
224
+ command: string,
225
+ ): Effect.Effect<CommandResult, VcsError> {
226
+ return result.ok
227
+ ? Effect.succeed(result)
228
+ : Effect.fail(
229
+ new VcsError({
230
+ message: `${command} failed: ${result.stderr || result.stdout}`,
231
+ command,
232
+ }),
233
+ )
234
+ }
235
+
236
+ function requireRawCommand(
237
+ result: ReturnType<VcsRawCommandRunner>,
238
+ command: string,
239
+ ): Effect.Effect<ReturnType<VcsRawCommandRunner>, VcsError> {
240
+ return result.ok
241
+ ? Effect.succeed(result)
242
+ : Effect.fail(
243
+ new VcsError({
244
+ message: `${command} failed: ${result.stderr}`,
245
+ command,
246
+ }),
247
+ )
248
+ }
249
+
250
+ function splitNullBuffers(value: Buffer): Buffer[] {
251
+ const parts: Buffer[] = []
252
+ let start = 0
253
+ for (let index = 0; index < value.length; index++) {
254
+ if (value[index] !== 0) continue
255
+ if (index > start) parts.push(value.subarray(start, index))
256
+ start = index + 1
257
+ }
258
+ if (start < value.length) parts.push(value.subarray(start))
259
+ return parts
260
+ }
261
+
262
+ function digest(parts: readonly (string | Buffer)[]): string {
263
+ if (parts.every((part) => part.length === 0)) return ""
264
+ const hash = createHash("sha256")
265
+ for (const part of parts) hash.update(part)
266
+ return hash.digest("hex")
267
+ }
268
+
269
+ function rejectCaseFoldedApneaAlias(
270
+ root: string,
271
+ ): Effect.Effect<void, VcsError> {
272
+ return Effect.try({
273
+ try: () => {
274
+ const alias = readdirSync(root).find(
275
+ (name) => name.toLowerCase() === ".apnea" && name !== ".apnea",
276
+ )
277
+ if (alias !== undefined) {
278
+ throw new VcsError({
279
+ message: `refusing case-insensitive .apnea alias at repository root: ${alias}`,
280
+ })
281
+ }
282
+ },
283
+ catch: (error) =>
284
+ error instanceof VcsError
285
+ ? error
286
+ : new VcsError({
287
+ message: `could not inspect repository root for .apnea aliases: ${error instanceof Error ? error.message : String(error)}`,
288
+ }),
289
+ })
290
+ }
291
+
292
+ type FingerprintLimits = {
293
+ readonly maxBytes: number
294
+ readonly timeoutMs: number
295
+ }
296
+
297
+ export function fingerprintUntrackedFiles(
298
+ root: string,
299
+ files: readonly (string | Buffer)[],
300
+ limits: FingerprintLimits = {
301
+ maxBytes: UNTRACKED_FINGERPRINT_MAX_BYTES,
302
+ timeoutMs: UNTRACKED_FINGERPRINT_TIMEOUT_MS,
303
+ },
304
+ ): Effect.Effect<string, VcsError> {
305
+ return Effect.try({
306
+ try: () => {
307
+ if (files.length === 0) return ""
308
+ if (
309
+ !Number.isFinite(limits.maxBytes) ||
310
+ limits.maxBytes < 0 ||
311
+ !Number.isFinite(limits.timeoutMs) ||
312
+ limits.timeoutMs < 0
313
+ ) {
314
+ throw new VcsError({ message: "invalid untracked fingerprint limits" })
315
+ }
316
+ const startedAt = Date.now()
317
+ const hash = createHash("sha256")
318
+ const buffer = Buffer.allocUnsafe(64 * 1024)
319
+ let totalBytes = 0
320
+
321
+ const account = (bytes: number) => {
322
+ totalBytes += bytes
323
+ if (totalBytes > limits.maxBytes) {
324
+ throw new VcsError({
325
+ message: `untracked fingerprint byte limit exceeded (${limits.maxBytes} bytes)`,
326
+ })
327
+ }
328
+ if (Date.now() - startedAt > limits.timeoutMs) {
329
+ throw new VcsError({
330
+ message: `untracked fingerprint timed out after ${limits.timeoutMs}ms`,
331
+ })
332
+ }
333
+ }
334
+
335
+ for (const file of files) {
336
+ const rawFile = Buffer.isBuffer(file) ? file : Buffer.from(file)
337
+ const components = splitNullBuffers(
338
+ Buffer.from(rawFile.map((byte) => (byte === 0x2f ? 0 : byte))),
339
+ )
340
+ if (
341
+ rawFile.length === 0 ||
342
+ rawFile[0] === 0x2f ||
343
+ components.some(
344
+ (component) =>
345
+ component.length === 2 &&
346
+ component[0] === 0x2e &&
347
+ component[1] === 0x2e,
348
+ )
349
+ ) {
350
+ throw new VcsError({
351
+ message: `invalid untracked path from VCS: ${rawFile.toString("hex")}`,
352
+ })
353
+ }
354
+ const absolute = Buffer.concat([
355
+ Buffer.from(`${path.resolve(root)}${path.sep}`),
356
+ rawFile,
357
+ ])
358
+ const display = rawFile.toString("hex")
359
+ const before = lstatSync(absolute)
360
+ hash.update(rawFile)
361
+ hash.update("\0")
362
+ if (before.isSymbolicLink()) {
363
+ const target = readlinkSync(absolute, { encoding: "buffer" })
364
+ const after = lstatSync(absolute)
365
+ if (
366
+ !after.isSymbolicLink() ||
367
+ after.dev !== before.dev ||
368
+ after.ino !== before.ino ||
369
+ after.mtimeMs !== before.mtimeMs ||
370
+ after.ctimeMs !== before.ctimeMs
371
+ ) {
372
+ throw new VcsError({
373
+ message: `untracked symlink changed while fingerprinting (hex path): ${display}`,
374
+ })
375
+ }
376
+ account(target.length)
377
+ hash.update("symlink\0")
378
+ hash.update(target)
379
+ hash.update("\0")
380
+ continue
381
+ }
382
+ if (!before.isFile()) {
383
+ throw new VcsError({
384
+ message: `untracked fingerprints accept only regular files or symlinks (hex path): ${display}`,
385
+ })
386
+ }
387
+
388
+ let descriptor: number | undefined
389
+ try {
390
+ descriptor = openSync(
391
+ absolute,
392
+ process.platform === "win32"
393
+ ? "r"
394
+ : fsConstants.O_RDONLY |
395
+ fsConstants.O_NOFOLLOW |
396
+ fsConstants.O_NONBLOCK,
397
+ )
398
+ const opened = fstatSync(descriptor)
399
+ if (
400
+ !opened.isFile() ||
401
+ opened.dev !== before.dev ||
402
+ opened.ino !== before.ino
403
+ ) {
404
+ throw new VcsError({
405
+ message: `untracked file changed while fingerprinting (hex path): ${display}`,
406
+ })
407
+ }
408
+ if (opened.size > limits.maxBytes - totalBytes) {
409
+ throw new VcsError({
410
+ message: `untracked fingerprint byte limit exceeded (${limits.maxBytes} bytes)`,
411
+ })
412
+ }
413
+ hash.update("file\0")
414
+ for (;;) {
415
+ const bytes = readSync(descriptor, buffer, 0, buffer.length, null)
416
+ if (bytes === 0) break
417
+ account(bytes)
418
+ hash.update(buffer.subarray(0, bytes))
419
+ }
420
+ const after = fstatSync(descriptor)
421
+ if (
422
+ after.size !== opened.size ||
423
+ after.mtimeMs !== opened.mtimeMs ||
424
+ after.ctimeMs !== opened.ctimeMs
425
+ ) {
426
+ throw new VcsError({
427
+ message: `untracked file changed while fingerprinting (hex path): ${display}`,
428
+ })
429
+ }
430
+ hash.update("\0")
431
+ } finally {
432
+ if (descriptor !== undefined) closeSync(descriptor)
433
+ }
434
+ }
435
+ return hash.digest("hex")
436
+ },
437
+ catch: (error) =>
438
+ error instanceof VcsError
439
+ ? error
440
+ : new VcsError({
441
+ message: `could not fingerprint untracked files: ${error instanceof Error ? error.message : String(error)}`,
442
+ }),
443
+ })
444
+ }
445
+
446
+ export function treeFingerprintWithCommand(
447
+ root: string,
448
+ vcs: VcsBackend,
449
+ runCommand: VcsCommandRunner,
450
+ runRawCommand: VcsRawCommandRunner = runRaw,
451
+ ): Effect.Effect<string, VcsError> {
452
+ return Effect.gen(function* () {
453
+ if (vcs === "jj") {
454
+ const command = `jj diff --git --color=never -- ${JJ_NOT_APNEA_ICASE}`
455
+ const result = yield* requireCommand(
456
+ runCommand(
457
+ "jj",
458
+ ["diff", "--git", "--color=never", "--", JJ_NOT_APNEA_ICASE],
459
+ root,
460
+ ),
461
+ command,
462
+ )
463
+ return digest([result.stdout])
464
+ }
465
+ const pathspec = ["--", ".", ...APNEA_ICASE_EXCLUDES]
466
+ const staged = yield* requireCommand(
467
+ runCommand(
468
+ "git",
469
+ ["diff", "--binary", "--no-ext-diff", "--cached", ...pathspec],
470
+ root,
471
+ ),
472
+ "git diff --cached",
473
+ )
474
+ const unstaged = yield* requireCommand(
475
+ runCommand(
476
+ "git",
477
+ ["diff", "--binary", "--no-ext-diff", ...pathspec],
478
+ root,
479
+ ),
480
+ "git diff",
481
+ )
482
+ const untracked = yield* requireRawCommand(
483
+ runRawCommand(
484
+ "git",
485
+ ["ls-files", "--others", "--exclude-standard", "-z", ...pathspec],
486
+ root,
487
+ ),
488
+ "git ls-files --others",
489
+ )
490
+ const untrackedFingerprint = yield* fingerprintUntrackedFiles(
491
+ root,
492
+ splitNullBuffers(untracked.stdout),
493
+ )
494
+ if (
495
+ staged.stdout.length === 0 &&
496
+ unstaged.stdout.length === 0 &&
497
+ untrackedFingerprint.length === 0
498
+ ) {
499
+ return ""
500
+ }
501
+ return digest([
502
+ "staged\0",
503
+ staged.stdout,
504
+ "\0unstaged\0",
505
+ unstaged.stdout,
506
+ "\0untracked\0",
507
+ untrackedFingerprint,
508
+ ])
509
+ })
510
+ }
511
+
512
+ /**
513
+ * Fingerprint the non-`.apnea` diff of a single jj revision. Used for the
514
+ * pending-commit content anchor: computed over `@` at preparation and
515
+ * recomputed over the same change at completion to detect drift.
516
+ */
517
+ export function jjRevisionFingerprintWithCommand(
518
+ root: string,
519
+ revision: string,
520
+ runCommand: VcsCommandRunner = run,
521
+ ): Effect.Effect<string, VcsError> {
522
+ return Effect.gen(function* () {
523
+ const result = yield* requireCommand(
524
+ runCommand(
525
+ "jj",
526
+ [
527
+ "diff",
528
+ "--git",
529
+ "--color=never",
530
+ "-r",
531
+ revision,
532
+ "--",
533
+ JJ_NOT_APNEA_ICASE,
534
+ ],
535
+ root,
536
+ ),
537
+ `jj diff --git -r ${revision}`,
538
+ )
539
+ return digest([result.stdout])
540
+ })
541
+ }
542
+
62
543
  function verificationError(
63
544
  error: unknown,
64
545
  temporaryDirectory?: string,
@@ -74,7 +555,6 @@ function verificationError(
74
555
  }
75
556
 
76
557
  const VERIFY_LOG_LIMIT = 10 * 1024 * 1024
77
- const VERIFY_KILL_CLOSE_GRACE_MS = 2_500
78
558
  const VERIFY_RESULT_RESERVE = 2_048
79
559
  const VERIFY_WRAPPER_SOURCE = `exec 2>&1
80
560
  exec "$1" -e "$2"
@@ -160,96 +640,6 @@ export function verifyBlockDisplayByteLength(block: VerifyBlock): number {
160
640
  )
161
641
  }
162
642
 
163
- async function taskkillTree(pid: number): Promise<boolean> {
164
- return new Promise((resolve) => {
165
- let settled = false
166
- let killer: ChildProcess
167
- const finish = (ok: boolean) => {
168
- if (settled) return
169
- settled = true
170
- clearTimeout(timer)
171
- resolve(ok)
172
- }
173
-
174
- try {
175
- killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
176
- stdio: "ignore",
177
- windowsHide: true,
178
- })
179
- } catch {
180
- resolve(false)
181
- return
182
- }
183
-
184
- const timer = setTimeout(() => {
185
- try {
186
- killer.kill("SIGKILL")
187
- } catch {
188
- // The fallback below still targets the verification child.
189
- }
190
- finish(false)
191
- }, 2_000)
192
- killer.once("error", () => finish(false))
193
- killer.once("close", (code) => finish(code === 0))
194
- })
195
- }
196
-
197
- function snapshotProcessDescendants(rootPid: number): number[] {
198
- const snapshot = spawnSync("ps", ["-axo", "pid=,ppid="], {
199
- encoding: "utf8",
200
- maxBuffer: 10 * 1024 * 1024,
201
- })
202
- if (snapshot.status !== 0 || snapshot.error) return []
203
-
204
- const children = new Map<number, number[]>()
205
- for (const line of (snapshot.stdout ?? "").split("\n")) {
206
- const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
207
- if (!match) continue
208
- const pid = Number(match[1])
209
- const parentPid = Number(match[2])
210
- const siblings = children.get(parentPid)
211
- if (siblings) siblings.push(pid)
212
- else children.set(parentPid, [pid])
213
- }
214
-
215
- const descendants: number[] = []
216
- const pending = [...(children.get(rootPid) ?? [])]
217
- for (let index = 0; index < pending.length; index++) {
218
- const pid = pending[index]!
219
- descendants.push(pid)
220
- pending.push(...(children.get(pid) ?? []))
221
- }
222
- return descendants
223
- }
224
-
225
- async function killProcessTree(child: ChildProcess): Promise<void> {
226
- const pid = child.pid
227
- if (process.platform === "win32" && pid !== undefined) {
228
- if (await taskkillTree(pid)) return
229
- } else if (pid !== undefined) {
230
- const descendants = snapshotProcessDescendants(pid)
231
- try {
232
- process.kill(-pid, "SIGKILL")
233
- } catch {
234
- // The process may have exited between timeout and termination.
235
- }
236
- for (const descendantPid of descendants) {
237
- try {
238
- process.kill(descendantPid, "SIGKILL")
239
- } catch {
240
- // Process-group termination may already have killed this descendant.
241
- }
242
- }
243
- }
244
-
245
- // This is also the fallback when Windows taskkill cannot kill the tree.
246
- try {
247
- child.kill("SIGKILL")
248
- } catch {
249
- // A concurrently exited child needs no further termination.
250
- }
251
- }
252
-
253
643
  type VerificationProcessResult = {
254
644
  code: number
255
645
  output: string
@@ -257,99 +647,54 @@ type VerificationProcessResult = {
257
647
  }
258
648
 
259
649
  function runVerificationProcess(
650
+ processService: ProcessService,
260
651
  wrapper: string,
261
652
  interpreter: VerifyBlock["interpreter"],
262
653
  script: string,
263
654
  cwd: string,
264
655
  timeoutMs: number,
265
656
  outputLimit: number,
266
- ): Promise<VerificationProcessResult> {
267
- return new Promise((resolve) => {
268
- let child: ChildProcess
269
- try {
270
- child = spawn("sh", [wrapper, interpreter, script], {
657
+ reportedTimeoutMs = timeoutMs,
658
+ ): Effect.Effect<VerificationProcessResult> {
659
+ return Effect.gen(function* () {
660
+ const result = yield* Effect.result(
661
+ processService.run({
662
+ command: "sh",
663
+ args: [wrapper, interpreter, script],
271
664
  cwd,
272
- detached: process.platform !== "win32",
273
- stdio: ["ignore", "pipe", "ignore"],
274
- windowsHide: true,
275
- })
276
- } catch (error) {
277
- resolve({
278
- code: 1,
279
- output: "",
280
- error: verificationError(error),
281
- })
282
- return
283
- }
284
-
285
- const output: Buffer[] = []
286
- let outputSize = 0
287
- let outputExceeded = false
288
- let processError: string | undefined
289
- let termination: Promise<void> | undefined
290
- let timeout: ReturnType<typeof setTimeout> | undefined
291
- let postKillCompletion: ReturnType<typeof setTimeout> | undefined
292
- let settled = false
293
-
294
- const terminate = (message: string) => {
295
- if (settled) return
296
- processError ??= message
297
- if (termination) return
298
- termination = killProcessTree(child)
299
- postKillCompletion = setTimeout(() => {
300
- child.stdout?.destroy()
301
- finish(1)
302
- }, VERIFY_KILL_CLOSE_GRACE_MS)
665
+ timeoutMs,
666
+ outputLimitBytes: Math.max(1, outputLimit),
667
+ }),
668
+ )
669
+ if (Result.isSuccess(result)) {
670
+ return { code: result.success.exitCode, output: result.success.stdout }
303
671
  }
304
- const capture = (chunk: Buffer) => {
305
- if (outputExceeded) return
306
- const available = outputLimit - outputSize
307
- if (chunk.length > available) {
308
- if (available > 0) output.push(chunk.subarray(0, available))
309
- outputSize = outputLimit
310
- outputExceeded = true
311
- terminate(`verification output exceeded ${outputLimit} bytes`)
312
- return
672
+ const error = result.failure
673
+ if (error instanceof ProcessExitError) {
674
+ return {
675
+ code: error.exitCode,
676
+ output: `${error.stdout}${error.stderr}`,
313
677
  }
314
- output.push(chunk)
315
- outputSize += chunk.length
316
- }
317
- const onStdout = (chunk: Buffer) => capture(chunk)
318
- const onError = (error: Error) => {
319
- terminate(`verification process error: ${verificationError(error)}`)
320
678
  }
321
- const finish = (code: number) => {
322
- if (settled) return
323
- settled = true
324
- if (timeout) clearTimeout(timeout)
325
- if (postKillCompletion) clearTimeout(postKillCompletion)
326
- child.stdout?.off("data", onStdout)
327
- child.off("error", onError)
328
- child.off("close", onClose)
329
- resolve({
330
- code,
331
- output: Buffer.concat(output).toString("utf8"),
332
- ...(processError === undefined ? {} : { error: processError }),
333
- })
679
+ if (error instanceof ProcessTimeoutError) {
680
+ return {
681
+ code: 1,
682
+ output: `${error.stdout}${error.stderr}`,
683
+ error: `verification timed out after ${reportedTimeoutMs}ms`,
684
+ }
334
685
  }
335
- const onClose = (code: number | null) => {
336
- if (termination) {
337
- void termination.then(
338
- () => finish(code ?? 1),
339
- () => finish(code ?? 1),
340
- )
341
- } else {
342
- finish(code ?? 1)
686
+ if (error instanceof ProcessOutputError) {
687
+ return {
688
+ code: 1,
689
+ output: `${error.stdout}${error.stderr}`,
690
+ error: `verification output exceeded ${outputLimit} bytes`,
343
691
  }
344
692
  }
345
-
346
- child.stdout?.on("data", onStdout)
347
- child.once("error", onError)
348
- child.once("close", onClose)
349
-
350
- timeout = setTimeout(() => {
351
- terminate(`verification timed out after ${timeoutMs}ms`)
352
- }, timeoutMs)
693
+ return {
694
+ code: 1,
695
+ output: "stdout" in error ? `${error.stdout}${error.stderr}` : "",
696
+ error: `verification process error: ${verificationError(error)}`,
697
+ }
353
698
  })
354
699
  }
355
700
 
@@ -362,24 +707,699 @@ export function filterAppPaths(summary: string): string {
362
707
  if (!t) return false
363
708
  // git porcelain: XY path
364
709
  if (/^.. /.test(line)) {
365
- const p = line.slice(3).replace(/^"|"$/g, "")
710
+ const p = line.slice(3).replace(/^"|"$/g, "").toLowerCase()
366
711
  return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
367
712
  }
368
713
  // jj summary often: M path / A path
369
714
  const m = t.match(/^[A-Z]+\s+(.+)$/)
370
715
  if (m) {
371
- const p = m[1]!
716
+ const p = m[1]!.toLowerCase()
372
717
  return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
373
718
  }
374
- return !t.includes(".apnea/")
719
+ return !t.toLowerCase().includes(".apnea/")
375
720
  })
376
721
  .join("\n")
377
722
  }
378
723
 
724
+ /**
725
+ * Current branch via `symbolic-ref -q`. Returns null for detached HEAD —
726
+ * with `-q`, Git signals that case as exit 1 with empty stdout, which
727
+ * `requireCommand` would otherwise swallow into a generic failure.
728
+ */
729
+ function gitCurrentBranchWithCommand(
730
+ root: string,
731
+ runCommand: VcsCommandRunner,
732
+ ): Effect.Effect<string | null, VcsError> {
733
+ return Effect.sync(() =>
734
+ runCommand("git", ["symbolic-ref", "-q", "HEAD"], root),
735
+ ).pipe(
736
+ Effect.flatMap((result) => {
737
+ if (result.ok) return Effect.succeed(result.stdout.trim())
738
+ if (result.code === 1 && result.stdout.trim() === "") {
739
+ return Effect.succeed(null)
740
+ }
741
+ return Effect.fail(
742
+ new VcsError({
743
+ message:
744
+ result.stderr || result.stdout || "git symbolic-ref -q HEAD failed",
745
+ command: "git symbolic-ref -q HEAD",
746
+ }),
747
+ )
748
+ }),
749
+ )
750
+ }
751
+
752
+ /**
753
+ * Prepare a Git commit transaction: stage everything except case-folded
754
+ * `.apnea` aliases in an isolated index, persist the resulting tree id, and
755
+ * append the `Apnea-Transaction:` trailer to the message body. No ref moves
756
+ * and no real-index mutation — safe to retry after a crash before completion.
757
+ */
758
+ export function gitPrepareWithCommand(
759
+ root: string,
760
+ message: string,
761
+ runCommand: VcsCommandRunner = run,
762
+ ): Effect.Effect<PreparedCommit, VcsError> {
763
+ return Effect.gen(function* () {
764
+ yield* rejectCaseFoldedApneaAlias(root)
765
+ const trackedRuntime = yield* requireCommand(
766
+ runCommand("git", ["ls-files", "-z", "--", APNEA_ICASE_PATHSPEC], root),
767
+ `git ls-files -- ${APNEA_ICASE_PATHSPEC}`,
768
+ )
769
+ const stagedRuntime = yield* requireCommand(
770
+ runCommand(
771
+ "git",
772
+ ["diff", "--cached", "--name-only", "-z", "--", APNEA_ICASE_PATHSPEC],
773
+ root,
774
+ ),
775
+ `git diff --cached --name-only -- ${APNEA_ICASE_PATHSPEC}`,
776
+ )
777
+ if (trackedRuntime.stdout.length > 0 || stagedRuntime.stdout.length > 0) {
778
+ return yield* new VcsError({
779
+ message: "refusing commit: .apnea is already tracked or staged",
780
+ command: "git ls-files/diff --cached with :(icase).apnea pathspec",
781
+ })
782
+ }
783
+
784
+ const head = yield* requireCommand(
785
+ runCommand("git", ["rev-parse", "--verify", "HEAD"], root),
786
+ "git rev-parse --verify HEAD",
787
+ )
788
+ const branch = yield* gitCurrentBranchWithCommand(root, runCommand)
789
+ if (branch === null) {
790
+ return yield* new VcsError({
791
+ message: "refusing commit: detached HEAD has no branch to update",
792
+ command: "git symbolic-ref -q HEAD",
793
+ })
794
+ }
795
+ const temporary = yield* Effect.try({
796
+ try: () => mkdtempSync(path.join(tmpdir(), "apnea-index-")),
797
+ catch: (error) =>
798
+ new VcsError({
799
+ message: `could not create isolated Git index: ${error instanceof Error ? error.message : String(error)}`,
800
+ }),
801
+ })
802
+ const index = path.join(temporary, "index")
803
+ const indexEnv = { GIT_INDEX_FILE: index }
804
+ try {
805
+ yield* requireCommand(
806
+ runCommand("git", ["read-tree", head.stdout.trim()], root, indexEnv),
807
+ "git read-tree HEAD",
808
+ )
809
+ yield* requireCommand(
810
+ runCommand(
811
+ "git",
812
+ ["add", "-A", "--", ".", ...APNEA_ICASE_EXCLUDES],
813
+ root,
814
+ indexEnv,
815
+ ),
816
+ "git add with isolated index",
817
+ )
818
+ yield* rejectCaseFoldedApneaAlias(root)
819
+ const isolatedRuntime = yield* requireCommand(
820
+ runCommand(
821
+ "git",
822
+ ["ls-files", "-z", "--", APNEA_ICASE_PATHSPEC],
823
+ root,
824
+ indexEnv,
825
+ ),
826
+ "git ls-files isolated index",
827
+ )
828
+ if (isolatedRuntime.stdout.length > 0) {
829
+ return yield* new VcsError({
830
+ message: "refusing commit: isolated tree contains .apnea",
831
+ })
832
+ }
833
+ const tree = yield* requireCommand(
834
+ runCommand("git", ["write-tree"], root, indexEnv),
835
+ "git write-tree",
836
+ )
837
+ const treeRuntime = yield* requireCommand(
838
+ runCommand(
839
+ "git",
840
+ ["ls-tree", "-r", "--name-only", "-z", tree.stdout.trim()],
841
+ root,
842
+ ),
843
+ "git ls-tree isolated tree",
844
+ )
845
+ if (
846
+ treeRuntime.stdout
847
+ .split("\0")
848
+ .filter(Boolean)
849
+ .some((file) => file.split("/", 1)[0]!.toLowerCase() === ".apnea")
850
+ ) {
851
+ return yield* new VcsError({
852
+ message: "refusing commit: written tree contains .apnea",
853
+ })
854
+ }
855
+ const id = randomUUID()
856
+ return {
857
+ backend: "git" as const,
858
+ id,
859
+ message: withTransactionTrailer(message, id),
860
+ branch,
861
+ parent_commit: head.stdout.trim(),
862
+ tree_id: tree.stdout.trim(),
863
+ }
864
+ } finally {
865
+ yield* Effect.try({
866
+ try: () => rmSync(temporary, { recursive: true, force: true }),
867
+ catch: (error) =>
868
+ new VcsError({
869
+ message: `could not remove isolated Git index: ${error instanceof Error ? error.message : String(error)}`,
870
+ }),
871
+ })
872
+ }
873
+ })
874
+ }
875
+
876
+ type GitHeadInfo = {
877
+ hash: string
878
+ tree: string
879
+ firstParent: string | null
880
+ body: string
881
+ }
882
+
883
+ function gitHeadInfoWithCommand(
884
+ root: string,
885
+ runCommand: VcsCommandRunner,
886
+ ): Effect.Effect<GitHeadInfo, VcsError> {
887
+ return Effect.gen(function* () {
888
+ const shown = yield* requireCommand(
889
+ runCommand(
890
+ "git",
891
+ ["show", "-s", "--format=%H%n%T%n%P%n%B", "HEAD"],
892
+ root,
893
+ ),
894
+ "git show -s HEAD",
895
+ )
896
+ const lines = shown.stdout.split("\n")
897
+ const parents = (lines[2] ?? "").trim()
898
+ return {
899
+ hash: (lines[0] ?? "").trim(),
900
+ tree: (lines[1] ?? "").trim(),
901
+ firstParent: parents === "" ? null : parents.split(" ")[0]!,
902
+ body: lines.slice(3).join("\n"),
903
+ }
904
+ })
905
+ }
906
+
907
+ /**
908
+ * Complete (or recognize) a prepared Git transaction exactly once:
909
+ *
910
+ * - HEAD still at the recorded parent → validate branch and tree, then create
911
+ * the commit (`commit-tree` + CAS `update-ref`, signing preserved).
912
+ * - HEAD is a commit whose message carries this transaction's
913
+ * `Apnea-Transaction:` marker and whose parent and tree match the anchor →
914
+ * treat as completed and return its id.
915
+ * - Anything else → refuse, naming the drift.
916
+ */
917
+ export function gitCompleteWithCommand(
918
+ root: string,
919
+ pending: GitPendingCommit,
920
+ runCommand: VcsCommandRunner = run,
921
+ runMutation: VcsMutationRunner = syncMutationRunner(run),
922
+ ): Effect.Effect<string, VcsError> {
923
+ return Effect.gen(function* () {
924
+ if (!isUuid(pending.id)) {
925
+ return yield* new VcsError({
926
+ message: `refusing commit: pending_commit.id is not a uuid: ${pending.id}`,
927
+ })
928
+ }
929
+ const currentBranch = yield* gitCurrentBranchWithCommand(root, runCommand)
930
+ if (currentBranch !== pending.branch) {
931
+ return yield* new VcsError({
932
+ message: `refusing commit: branch drifted since preparation (expected ${pending.branch}, found ${currentBranch ?? "(detached HEAD)"})`,
933
+ command: "git symbolic-ref -q HEAD",
934
+ })
935
+ }
936
+ const head = yield* gitHeadInfoWithCommand(root, runCommand)
937
+
938
+ if (head.hash !== pending.parent_commit) {
939
+ // Either the crash hit after the commit landed (recognize it), or the
940
+ // repository drifted for unrelated reasons (refuse). The marker alone
941
+ // is not proof — parent and tree must match the prepared anchor too.
942
+ if (!head.body.includes(`${TRANSACTION_TRAILER_PREFIX} ${pending.id}`)) {
943
+ return yield* new VcsError({
944
+ message: `refusing commit: HEAD moved since preparation without this transaction's marker (expected ${pending.parent_commit}, found ${head.hash})`,
945
+ })
946
+ }
947
+ if (head.firstParent !== pending.parent_commit) {
948
+ return yield* new VcsError({
949
+ message: `refusing commit: marked transaction commit has unexpected parent (expected ${pending.parent_commit}, found ${head.firstParent ?? "(root)"})`,
950
+ })
951
+ }
952
+ if (head.tree !== pending.tree_id) {
953
+ return yield* new VcsError({
954
+ message: `refusing commit: marked transaction commit has unexpected tree (expected ${pending.tree_id}, found ${head.tree})`,
955
+ })
956
+ }
957
+ return head.hash
958
+ }
959
+
960
+ // Create case: HEAD sits at the recorded parent. The tree was validated
961
+ // at preparation time and tree objects are immutable, so only its
962
+ // continued existence needs checking.
963
+ yield* requireCommand(
964
+ runCommand("git", ["cat-file", "-e", `${pending.tree_id}^{tree}`], root),
965
+ `git cat-file -e ${pending.tree_id}^{tree}`,
966
+ )
967
+ const signing = runCommand(
968
+ "git",
969
+ ["config", "--bool", "commit.gpgsign"],
970
+ root,
971
+ )
972
+ if (!signing.ok && signing.code !== 1) {
973
+ return yield* new VcsError({
974
+ message: signing.stderr || signing.stdout,
975
+ command: "git config --bool commit.gpgsign",
976
+ })
977
+ }
978
+ const commitArgs = [
979
+ "commit-tree",
980
+ pending.tree_id,
981
+ "-p",
982
+ pending.parent_commit,
983
+ "-m",
984
+ pending.message,
985
+ ...(signing.ok && signing.stdout.trim() === "true" ? ["-S"] : []),
986
+ ]
987
+ const committed = yield* requireCommand(
988
+ yield* runMutation("git", commitArgs, root),
989
+ "git commit-tree",
990
+ )
991
+
992
+ // The real index must match the validated tree before the branch can move.
993
+ // Accepted tradeoff: if the CAS update-ref below loses a race, the index
994
+ // briefly describes an unreachable commit until the next Git command
995
+ // re-reads HEAD. Rewinding it here would add another mutating window to
996
+ // recover from; staleness is safe because the index is rebuilt from the
997
+ // branch tip on the next checkout/reset.
998
+ yield* requireCommand(
999
+ yield* runMutation("git", ["read-tree", committed.stdout.trim()], root),
1000
+ "git read-tree committed tree",
1001
+ )
1002
+ yield* requireCommand(
1003
+ yield* runMutation(
1004
+ "git",
1005
+ [
1006
+ "update-ref",
1007
+ pending.branch,
1008
+ committed.stdout.trim(),
1009
+ pending.parent_commit,
1010
+ ],
1011
+ root,
1012
+ ),
1013
+ "git update-ref (compare-and-swap)",
1014
+ )
1015
+ return committed.stdout.trim()
1016
+ })
1017
+ }
1018
+
1019
+ export function runVerifyWithProcess(
1020
+ root: string,
1021
+ blocks: readonly VerifyBlock[],
1022
+ timeoutMs: number,
1023
+ processService: ProcessService,
1024
+ ): Effect.Effect<{ ok: boolean; log: string }> {
1025
+ return Effect.gen(function* () {
1026
+ const log = new VerificationLog(VERIFY_LOG_LIMIT)
1027
+ const startedAt = yield* Clock.currentTimeNanos
1028
+ const deadline =
1029
+ startedAt + BigInt(Math.max(0, Math.floor(timeoutMs))) * 1_000_000n
1030
+ let temporaryDirectory: string | undefined
1031
+ let ok = true
1032
+ let operation = "create temporary verification directory"
1033
+
1034
+ const remainingMs = (): Effect.Effect<number> =>
1035
+ Effect.gen(function* () {
1036
+ const now = yield* Clock.currentTimeNanos
1037
+ return Number((deadline - now) / 1_000_000n)
1038
+ })
1039
+
1040
+ const work = Effect.gen(function* () {
1041
+ temporaryDirectory = yield* Effect.try({
1042
+ try: () => mkdtempSync(path.join(tmpdir(), "apnea-verify-")),
1043
+ catch: (error) => error,
1044
+ })
1045
+ const wrapper = path.join(temporaryDirectory, "run-block.sh")
1046
+ operation = "write verification wrapper"
1047
+ yield* Effect.try({
1048
+ try: () =>
1049
+ writeFileSync(wrapper, VERIFY_WRAPPER_SOURCE, {
1050
+ encoding: "utf8",
1051
+ mode: 0o600,
1052
+ }),
1053
+ catch: (error) => error,
1054
+ })
1055
+ for (const [index, block] of blocks.entries()) {
1056
+ const source = normalizeVerifySource(block.source)
1057
+ const normalizedBlock = { ...block, source }
1058
+ const script = path.join(
1059
+ temporaryDirectory,
1060
+ `block-${index + 1}.${block.interpreter}`,
1061
+ )
1062
+ const displayBytes =
1063
+ 2 + verifyBlockDisplayByteLength(normalizedBlock) + 1
1064
+ if (!log.canAppendBytes(displayBytes)) {
1065
+ log.addLimitNotice(VERIFY_DISPLAY_LIMIT_NOTICE)
1066
+ ok = false
1067
+ break
1068
+ }
1069
+ log.append(`$ ${formatVerifyBlock(normalizedBlock)}\n`)
1070
+ operation = `write ${block.interpreter} verification block`
1071
+ yield* Effect.try({
1072
+ try: () =>
1073
+ writeFileSync(script, source, {
1074
+ encoding: "utf8",
1075
+ mode: 0o600,
1076
+ }),
1077
+ catch: (error) => error,
1078
+ })
1079
+ const remaining = yield* remainingMs()
1080
+ if (remaining <= 0) {
1081
+ log.append(`verification timed out after ${timeoutMs}ms\n`)
1082
+ ok = false
1083
+ break
1084
+ }
1085
+ operation = `run ${block.interpreter} verification block`
1086
+ const result = yield* runVerificationProcess(
1087
+ processService,
1088
+ wrapper,
1089
+ block.interpreter,
1090
+ script,
1091
+ root,
1092
+ remaining,
1093
+ Math.max(0, log.remaining - VERIFY_RESULT_RESERVE),
1094
+ timeoutMs,
1095
+ )
1096
+ const output = verificationError(
1097
+ result.output.trimEnd(),
1098
+ temporaryDirectory,
1099
+ )
1100
+ if (output && !log.append(`${output}\n`)) {
1101
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
1102
+ ok = false
1103
+ break
1104
+ }
1105
+ if (!log.append(`exit=${result.code}\n`)) {
1106
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
1107
+ ok = false
1108
+ break
1109
+ }
1110
+ if (result.error) {
1111
+ const error = verificationError(result.error, temporaryDirectory)
1112
+ if (!log.append(`${error}\n`))
1113
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
1114
+ ok = false
1115
+ break
1116
+ }
1117
+ if (result.code !== 0) {
1118
+ ok = false
1119
+ break
1120
+ }
1121
+ if (index < blocks.length - 1 && !log.append("\n")) {
1122
+ log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
1123
+ ok = false
1124
+ break
1125
+ }
1126
+ }
1127
+ return { ok, log: log.toString() }
1128
+ }).pipe(
1129
+ Effect.catch((error) => {
1130
+ const message = `${operation} failed: ${verificationError(error, temporaryDirectory)}\n`
1131
+ if (!log.append(message)) log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
1132
+ ok = false
1133
+ return Effect.succeed({ ok, log: log.toString() })
1134
+ }),
1135
+ )
1136
+
1137
+ return yield* Effect.ensuring(
1138
+ work,
1139
+ Effect.sync(() => {
1140
+ if (temporaryDirectory) {
1141
+ rmSync(temporaryDirectory, { recursive: true, force: true })
1142
+ }
1143
+ }).pipe(Effect.ignore),
1144
+ )
1145
+ })
1146
+ }
1147
+
1148
+ /** Sentinel fingerprint of an empty (content-free) diff. */
1149
+ export const EMPTY_JJ_DIFF_FINGERPRINT = ""
1150
+
1151
+ const JJ_DRIFT_RECOVERY_GUIDANCE =
1152
+ "Inspect `.apnea/state.json` (pending_commit) and `jj log -r @- --no-graph -T 'description ++ \"\\n\" ++ change_id'`. " +
1153
+ "Recovery requires clearing pending_commit manually; Apnea never clears it automatically because the prepared commit may have already landed, and clearing would let the same phase commit twice."
1154
+
1155
+ /** Change id of a jj revision, empty when the revision is absent. */
1156
+ function jjChangeIdWithCommand(
1157
+ root: string,
1158
+ revision: string,
1159
+ runCommand: VcsCommandRunner,
1160
+ ): CommandResult {
1161
+ return runCommand(
1162
+ "jj",
1163
+ ["log", "-r", revision, "--no-graph", "-T", "change_id"],
1164
+ root,
1165
+ )
1166
+ }
1167
+
1168
+ /**
1169
+ * Prepare a jj commit transaction: describe `@` with the message (trailer
1170
+ * included) and persist its change id plus the non-`.apnea` content
1171
+ * fingerprint. Describing is idempotent and moves no ref: a crash before
1172
+ * `pending_commit` is saved simply describes again on retry.
1173
+ *
1174
+ * A change whose diff is only `.apnea` is refused here, before anything is
1175
+ * persisted or described: completing such a transaction would evict every
1176
+ * diff from the terminus during recovery, leaving an empty change that jj
1177
+ * abandons — wedging the transaction permanently.
1178
+ */
1179
+ export function jjPrepareWithCommand(
1180
+ root: string,
1181
+ message: string,
1182
+ runCommand: VcsCommandRunner = run,
1183
+ runMutation: VcsMutationRunner = syncMutationRunner(run),
1184
+ ): Effect.Effect<PreparedCommit, VcsError> {
1185
+ return Effect.gen(function* () {
1186
+ yield* rejectCaseFoldedApneaAlias(root)
1187
+ const trackedRuntime = yield* requireCommand(
1188
+ runCommand(
1189
+ "jj",
1190
+ ["file", "list", "-r", "@-", "--", JJ_APNEA_ICASE],
1191
+ root,
1192
+ ),
1193
+ `jj file list -r @- -- ${JJ_APNEA_ICASE}`,
1194
+ )
1195
+ if (trackedRuntime.stdout.trim()) {
1196
+ return yield* new VcsError({
1197
+ message:
1198
+ "refusing commit: .apnea exists in the committed parent snapshot",
1199
+ command: `jj file list -r @- -- ${JJ_APNEA_ICASE}`,
1200
+ })
1201
+ }
1202
+ const at = yield* requireCommand(
1203
+ jjChangeIdWithCommand(root, "@", runCommand),
1204
+ "jj log -r @",
1205
+ )
1206
+ const changeId = at.stdout.trim()
1207
+ if (!changeId) {
1208
+ return yield* new VcsError({
1209
+ message: "refusing commit: could not resolve the @ change id",
1210
+ command: "jj log -r @",
1211
+ })
1212
+ }
1213
+ // Fingerprint the non-.apnea diff of @ before describing; completion
1214
+ // recomputes it over the same revision to detect content drift.
1215
+ const fingerprint = yield* jjRevisionFingerprintWithCommand(
1216
+ root,
1217
+ changeId,
1218
+ runCommand,
1219
+ )
1220
+ if (fingerprint === EMPTY_JJ_DIFF_FINGERPRINT) {
1221
+ return yield* new VcsError({
1222
+ message:
1223
+ `refusing commit: @ (${changeId}) has no non-.apnea changes to commit; ` +
1224
+ "a transaction anchored here would abandon the change during recovery. Commit or stash the working copy first.",
1225
+ command: "jj diff -r @",
1226
+ })
1227
+ }
1228
+ const id = randomUUID()
1229
+ const trailerMessage = withTransactionTrailer(message, id)
1230
+ yield* requireCommand(
1231
+ yield* runMutation("jj", ["describe", "-m", trailerMessage], root),
1232
+ "jj describe",
1233
+ )
1234
+ return {
1235
+ backend: "jj" as const,
1236
+ id,
1237
+ message: trailerMessage,
1238
+ change_id: changeId,
1239
+ content_fingerprint: fingerprint,
1240
+ }
1241
+ })
1242
+ }
1243
+
1244
+ /**
1245
+ * Move any `.apnea` diffs the described terminus still carries back into
1246
+ * the working copy. `jj describe` snapshots all of `@`, so untracked
1247
+ * `.apnea` changes ride along; eviction keeps the commit's complement
1248
+ * invariant. Idempotent: a no-op once the diffs are already in `@`.
1249
+ */
1250
+ function evictApneaFromTerminus(
1251
+ root: string,
1252
+ changeId: string,
1253
+ runCommand: VcsCommandRunner,
1254
+ runMutation: VcsMutationRunner,
1255
+ ): Effect.Effect<void, VcsError> {
1256
+ return Effect.gen(function* () {
1257
+ const present = yield* requireCommand(
1258
+ runCommand(
1259
+ "jj",
1260
+ ["file", "list", "-r", changeId, "--", JJ_APNEA_ICASE],
1261
+ root,
1262
+ ),
1263
+ `jj file list -r ${changeId} -- ${JJ_APNEA_ICASE}`,
1264
+ )
1265
+ if (!present.stdout.trim()) return
1266
+ yield* requireCommand(
1267
+ yield* runMutation(
1268
+ "jj",
1269
+ ["squash", "--from", changeId, "--into", "@", "--", JJ_APNEA_ICASE],
1270
+ root,
1271
+ ),
1272
+ `jj squash --from ${changeId} --into @ -- ${JJ_APNEA_ICASE}`,
1273
+ )
1274
+ })
1275
+ }
1276
+
1277
+ /**
1278
+ * Complete (or recognize) a prepared jj transaction exactly once:
1279
+ *
1280
+ * - Target is still `@` → crash hit between describe and `jj new`; verify
1281
+ * marker and fingerprint, advance with `jj new`, evict `.apnea`.
1282
+ * - Target is `@-` → completion ran before the crash; verify marker and
1283
+ * fingerprint, finish an interrupted `.apnea` eviction.
1284
+ * - Anything else, or drifted content → refuse with typed guidance.
1285
+ */
1286
+ export function jjCompleteWithCommand(
1287
+ root: string,
1288
+ pending: JjPendingCommit,
1289
+ runCommand: VcsCommandRunner = run,
1290
+ runMutation: VcsMutationRunner = syncMutationRunner(run),
1291
+ ): Effect.Effect<string, VcsError> {
1292
+ return Effect.gen(function* () {
1293
+ if (!isUuid(pending.id)) {
1294
+ return yield* new VcsError({
1295
+ message: `refusing commit: pending_commit.id is not a uuid: ${pending.id}`,
1296
+ })
1297
+ }
1298
+ const at = (yield* requireCommand(
1299
+ jjChangeIdWithCommand(root, "@", runCommand),
1300
+ "jj log -r @",
1301
+ )).stdout.trim()
1302
+ const atMinus = (yield* requireCommand(
1303
+ jjChangeIdWithCommand(root, "@-", runCommand),
1304
+ "jj log -r @-",
1305
+ )).stdout.trim()
1306
+
1307
+ const marker = `${TRANSACTION_TRAILER_PREFIX} ${pending.id}`
1308
+ const descriptionOf = (
1309
+ rev: string,
1310
+ ): Effect.Effect<string | null, VcsError> =>
1311
+ Effect.gen(function* () {
1312
+ const r = yield* requireCommand(
1313
+ runCommand(
1314
+ "jj",
1315
+ ["log", "-r", rev, "--no-graph", "-T", "description"],
1316
+ root,
1317
+ ),
1318
+ `jj log -r ${rev} description`,
1319
+ )
1320
+ return r.stdout.includes(marker) ? r.stdout : null
1321
+ })
1322
+
1323
+ // Case 1: target already sits at @- — completion ran before the crash.
1324
+ if (atMinus === pending.change_id) {
1325
+ const description = yield* descriptionOf("@-")
1326
+ if (description === null) {
1327
+ return yield* new VcsError({
1328
+ message: `refusing commit: @- is ${pending.change_id} but its description lacks this transaction's marker`,
1329
+ })
1330
+ }
1331
+ const fingerprint = yield* jjRevisionFingerprintWithCommand(
1332
+ root,
1333
+ pending.change_id,
1334
+ runCommand,
1335
+ )
1336
+ if (fingerprint !== pending.content_fingerprint) {
1337
+ return yield* new VcsError({
1338
+ message: `refusing commit: prepared jj change ${pending.change_id} drifted from its recorded content fingerprint. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
1339
+ })
1340
+ }
1341
+ if (fingerprint === EMPTY_JJ_DIFF_FINGERPRINT) {
1342
+ return yield* new VcsError({
1343
+ message: `refusing commit: prepared jj change ${pending.change_id} has no non-.apnea content; completing would abandon it and wedge the transaction. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
1344
+ })
1345
+ }
1346
+ yield* evictApneaFromTerminus(
1347
+ root,
1348
+ pending.change_id,
1349
+ runCommand,
1350
+ runMutation,
1351
+ )
1352
+ return pending.change_id
1353
+ }
1354
+
1355
+ // Case 2: target is still @ — crash hit between describe and `jj new`.
1356
+ if (at === pending.change_id) {
1357
+ const description = yield* descriptionOf("@")
1358
+ if (description === null) {
1359
+ return yield* new VcsError({
1360
+ message: `refusing commit: @ is ${pending.change_id} but its description lacks this transaction's marker`,
1361
+ })
1362
+ }
1363
+ const fingerprint = yield* jjRevisionFingerprintWithCommand(
1364
+ root,
1365
+ pending.change_id,
1366
+ runCommand,
1367
+ )
1368
+ if (fingerprint !== pending.content_fingerprint) {
1369
+ return yield* new VcsError({
1370
+ message: `refusing commit: prepared jj change ${pending.change_id} drifted from its recorded content fingerprint. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
1371
+ })
1372
+ }
1373
+ if (fingerprint === EMPTY_JJ_DIFF_FINGERPRINT) {
1374
+ return yield* new VcsError({
1375
+ message: `refusing commit: prepared jj change ${pending.change_id} has no non-.apnea content; completing would abandon it and wedge the transaction. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
1376
+ })
1377
+ }
1378
+ yield* requireCommand(
1379
+ yield* runMutation("jj", ["new", pending.change_id], root),
1380
+ `jj new ${pending.change_id}`,
1381
+ )
1382
+ yield* evictApneaFromTerminus(
1383
+ root,
1384
+ pending.change_id,
1385
+ runCommand,
1386
+ runMutation,
1387
+ )
1388
+ return pending.change_id
1389
+ }
1390
+
1391
+ return yield* new VcsError({
1392
+ message: `refusing commit: prepared jj change ${pending.change_id} is neither @ nor @- (repository moved on since preparation). ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
1393
+ command: "jj log -r @-",
1394
+ })
1395
+ })
1396
+ }
1397
+
379
1398
  export const VcsLive = Layer.effect(
380
1399
  Vcs,
381
1400
  Effect.gen(function* () {
382
1401
  const fs = yield* FileSystem
1402
+ const processService = yield* Process
383
1403
 
384
1404
  const detect = (root: string): Effect.Effect<VcsBackend | null> =>
385
1405
  Effect.gen(function* () {
@@ -391,17 +1411,16 @@ export const VcsLive = Layer.effect(
391
1411
  const treeFingerprint = (
392
1412
  root: string,
393
1413
  vcs: VcsBackend,
394
- ): Effect.Effect<string> =>
395
- Effect.sync(() => {
396
- if (vcs === "jj") {
397
- const r = run("jj", ["diff", "--summary"], root)
398
- return filterAppPaths(r.stdout)
399
- }
400
- const r = run("git", ["status", "--porcelain"], root)
401
- return filterAppPaths(r.stdout)
1414
+ ): Effect.Effect<string, VcsError> =>
1415
+ Effect.gen(function* () {
1416
+ yield* rejectCaseFoldedApneaAlias(root)
1417
+ return yield* treeFingerprintWithCommand(root, vcs, run)
402
1418
  })
403
1419
 
404
- const isDirty = (root: string, vcs: VcsBackend): Effect.Effect<boolean> =>
1420
+ const isDirty = (
1421
+ root: string,
1422
+ vcs: VcsBackend,
1423
+ ): Effect.Effect<boolean, VcsError> =>
405
1424
  Effect.gen(function* () {
406
1425
  const fp = yield* treeFingerprint(root, vcs)
407
1426
  return fp.trim().length > 0
@@ -448,59 +1467,63 @@ export const VcsLive = Layer.effect(
448
1467
  return branch
449
1468
  })
450
1469
 
451
- const commitPhase = (
1470
+ const prepareCommit = (
452
1471
  root: string,
453
1472
  vcs: VcsBackend,
454
1473
  message: string,
455
- ): Effect.Effect<string, VcsError> =>
456
- Effect.gen(function* () {
457
- if (vcs === "jj") {
458
- const d = yield* Effect.sync(() =>
459
- run("jj", ["describe", "-m", message], root),
1474
+ ): Effect.Effect<PreparedCommit, VcsError> => {
1475
+ const mutate = processMutationRunner(processService)
1476
+ return vcs === "jj"
1477
+ ? jjPrepareWithCommand(root, message, run, mutate)
1478
+ : gitPrepareWithCommand(root, message, run)
1479
+ }
1480
+
1481
+ const completeCommit = (
1482
+ root: string,
1483
+ vcs: VcsBackend,
1484
+ pending: PendingCommit,
1485
+ ): Effect.Effect<string, VcsError> => {
1486
+ const mutate = processMutationRunner(processService)
1487
+ if (vcs === "jj") {
1488
+ if (pending.backend !== "jj") {
1489
+ return Effect.fail(
1490
+ new VcsError({
1491
+ message: `pending_commit anchor is ${pending.backend} but this run uses jj`,
1492
+ }),
460
1493
  )
461
- if (!d.ok) {
462
- return yield* new VcsError({
463
- message: d.stderr || d.stdout,
464
- command: "jj describe",
465
- })
466
- }
467
- const n = yield* Effect.sync(() => run("jj", ["new"], root))
468
- if (!n.ok) {
469
- return yield* new VcsError({
470
- message: n.stderr || n.stdout,
471
- command: "jj new",
472
- })
473
- }
474
- return "jj describe + new"
475
- }
476
- const add = yield* Effect.sync(() => run("git", ["add", "-A"], root))
477
- if (!add.ok) {
478
- return yield* new VcsError({
479
- message: add.stderr,
480
- command: "git add -A",
481
- })
482
1494
  }
483
- const c = yield* Effect.sync(() =>
484
- run("git", ["commit", "-m", message], root),
1495
+ return jjCompleteWithCommand(root, pending, run, mutate)
1496
+ }
1497
+ if (pending.backend !== "git") {
1498
+ return Effect.fail(
1499
+ new VcsError({
1500
+ message: `pending_commit anchor is ${pending.backend} but this run uses git`,
1501
+ }),
485
1502
  )
486
- if (!c.ok) {
487
- return yield* new VcsError({
488
- message: c.stderr || c.stdout,
489
- command: "git commit",
490
- })
491
- }
492
- return "git commit"
493
- })
1503
+ }
1504
+ return gitCompleteWithCommand(root, pending, run, mutate)
1505
+ }
494
1506
 
495
1507
  const setBookmarkAtTerminus = (
496
1508
  root: string,
497
1509
  slug: string,
498
- ): Effect.Effect<void> =>
499
- Effect.sync(() => {
1510
+ ): Effect.Effect<void, VcsError> =>
1511
+ Effect.gen(function* () {
500
1512
  const name = `apnea/${slug}`
501
1513
  const r = run("jj", ["bookmark", "set", name, "-r", "@-"], root)
502
1514
  if (!r.ok) {
503
- run("jj", ["bookmark", "create", name, "-r", "@-"], root)
1515
+ const fallback = run(
1516
+ "jj",
1517
+ ["bookmark", "create", name, "-r", "@-"],
1518
+ root,
1519
+ )
1520
+ if (!fallback.ok) {
1521
+ return yield* new VcsError({
1522
+ message:
1523
+ fallback.stderr || fallback.stdout || r.stderr || r.stdout,
1524
+ command: `jj bookmark set ${name} -r @-`,
1525
+ })
1526
+ }
504
1527
  }
505
1528
  })
506
1529
 
@@ -509,108 +1532,15 @@ export const VcsLive = Layer.effect(
509
1532
  blocks: readonly VerifyBlock[],
510
1533
  timeoutMs: number,
511
1534
  ): Effect.Effect<{ ok: boolean; log: string }> =>
512
- Effect.promise(async () => {
513
- const log = new VerificationLog(VERIFY_LOG_LIMIT)
514
- let temporaryDirectory: string | undefined
515
- let ok = true
516
- let operation = "create temporary verification directory"
517
- try {
518
- temporaryDirectory = mkdtempSync(path.join(tmpdir(), "apnea-verify-"))
519
- const wrapper = path.join(temporaryDirectory, "run-block.sh")
520
- operation = "write verification wrapper"
521
- writeFileSync(wrapper, VERIFY_WRAPPER_SOURCE, {
522
- encoding: "utf8",
523
- mode: 0o600,
524
- })
525
- for (const [index, block] of blocks.entries()) {
526
- const source = normalizeVerifySource(block.source)
527
- const normalizedBlock = { ...block, source }
528
- const script = path.join(
529
- temporaryDirectory,
530
- `block-${index + 1}.${block.interpreter}`,
531
- )
532
- const displayBytes =
533
- 2 + verifyBlockDisplayByteLength(normalizedBlock) + 1
534
- if (!log.canAppendBytes(displayBytes)) {
535
- log.addLimitNotice(VERIFY_DISPLAY_LIMIT_NOTICE)
536
- ok = false
537
- break
538
- }
539
- log.append(`$ ${formatVerifyBlock(normalizedBlock)}\n`)
540
- operation = `write ${block.interpreter} verification block`
541
- writeFileSync(script, source, {
542
- encoding: "utf8",
543
- mode: 0o600,
544
- })
545
- operation = `run ${block.interpreter} verification block`
546
- const result = await runVerificationProcess(
547
- wrapper,
548
- block.interpreter,
549
- script,
550
- root,
551
- timeoutMs,
552
- Math.max(0, log.remaining - VERIFY_RESULT_RESERVE),
553
- )
554
- const output = verificationError(
555
- result.output.trimEnd(),
556
- temporaryDirectory,
557
- )
558
- if (output && !log.append(`${output}\n`)) {
559
- log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
560
- ok = false
561
- break
562
- }
563
- if (!log.append(`exit=${result.code}\n`)) {
564
- log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
565
- ok = false
566
- break
567
- }
568
- if (result.error) {
569
- const error = verificationError(result.error, temporaryDirectory)
570
- if (!log.append(`${error}\n`)) {
571
- log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
572
- }
573
- ok = false
574
- break
575
- }
576
- if (result.code !== 0) {
577
- ok = false
578
- break
579
- }
580
- if (index < blocks.length - 1 && !log.append("\n")) {
581
- log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
582
- ok = false
583
- break
584
- }
585
- }
586
- } catch (error) {
587
- const message = `${operation} failed: ${verificationError(error, temporaryDirectory)}\n`
588
- if (!log.append(message)) {
589
- log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
590
- }
591
- ok = false
592
- } finally {
593
- if (temporaryDirectory) {
594
- try {
595
- rmSync(temporaryDirectory, { recursive: true, force: true })
596
- } catch (error) {
597
- const message = `clean up temporary verification directory failed: ${verificationError(error, temporaryDirectory)}\n`
598
- if (!log.append(message)) {
599
- log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
600
- }
601
- ok = false
602
- }
603
- }
604
- }
605
- return { ok, log: log.toString() }
606
- })
1535
+ runVerifyWithProcess(root, blocks, timeoutMs, processService)
607
1536
 
608
1537
  return Vcs.of({
609
1538
  detect,
610
1539
  isDirty,
611
1540
  treeFingerprint,
612
1541
  ensureGitBranch,
613
- commitPhase,
1542
+ prepareCommit,
1543
+ completeCommit,
614
1544
  setBookmarkAtTerminus,
615
1545
  runVerify,
616
1546
  })