@naxodev/apnea 0.1.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 (51) hide show
  1. package/README.md +25 -10
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8571 -15324
  5. package/docs/adr/0005-harness-profiles.md +1 -1
  6. package/docs/adr/0010-package-split.md +1 -1
  7. package/docs/protocol/artifacts.md +18 -2
  8. package/docs/protocol/config.md +22 -25
  9. package/docs/protocol/manual-gate.md +8 -8
  10. package/docs/protocol/overview.md +18 -5
  11. package/extension/adapters/commit.ts +5 -1
  12. package/extension/adapters/dispatch.ts +9 -1
  13. package/extension/adapters/setup.ts +15 -1
  14. package/extension/adapters/start.ts +5 -1
  15. package/extension/adapters/status.ts +17 -2
  16. package/extension/adapters/wait.ts +6 -1
  17. package/extension/api.ts +7 -1
  18. package/extension/cli/main.ts +67 -7
  19. package/extension/cli/parse.ts +172 -5
  20. package/extension/domain/herdr.ts +0 -86
  21. package/extension/domain/paths.ts +3 -13
  22. package/extension/domain/setup.ts +0 -20
  23. package/extension/domain/timeouts.ts +4 -0
  24. package/extension/domain/types.ts +64 -11
  25. package/extension/domain/verify-commands.ts +200 -108
  26. package/extension/errors.ts +51 -16
  27. package/extension/operation-hooks.ts +6 -0
  28. package/extension/registry.ts +29 -15
  29. package/extension/run-tool.ts +19 -2
  30. package/extension/schema/config.ts +86 -30
  31. package/extension/schema/frontmatter.ts +57 -0
  32. package/extension/schema/state.ts +226 -16
  33. package/extension/services/app-live.ts +2 -1
  34. package/extension/services/config.ts +6 -4
  35. package/extension/services/file-system.ts +346 -75
  36. package/extension/services/herdr.ts +393 -402
  37. package/extension/services/operation-lock.ts +418 -0
  38. package/extension/services/process.ts +477 -0
  39. package/extension/services/run-store.ts +38 -16
  40. package/extension/services/vcs.ts +1388 -86
  41. package/extension/workflows/commit.ts +222 -18
  42. package/extension/workflows/dispatch.ts +320 -220
  43. package/extension/workflows/setup.ts +61 -141
  44. package/extension/workflows/start.ts +6 -5
  45. package/extension/workflows/status.ts +2 -2
  46. package/extension/workflows/wait.ts +63 -134
  47. package/package.json +2 -3
  48. package/schemas/config.schema.json +11 -7
  49. package/schemas/state.schema.json +170 -12
  50. package/herdr-plugin/herdr-plugin.toml +0 -15
  51. package/herdr-plugin/scripts/run-task.sh +0 -8
@@ -1,57 +1,703 @@
1
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"
16
+ import { tmpdir } from "node:os"
2
17
  import * as path from "node:path"
3
- import { Context, Effect, Layer } from "effect"
18
+ import { Clock, Context, Effect, Layer, Result } from "effect"
19
+ import {
20
+ formatVerifyBlock,
21
+ normalizeVerifySource,
22
+ type VerifyBlock,
23
+ } from "../domain/verify-commands.ts"
4
24
  import { VcsError } from "../errors.ts"
5
- import type { VcsBackend } from "../domain/types.ts"
25
+ import type {
26
+ GitPendingCommit,
27
+ JjPendingCommit,
28
+ PendingCommit,
29
+ VcsBackend,
30
+ } from "../domain/types.ts"
6
31
  import { FileSystem } from "./file-system.ts"
32
+ import {
33
+ Process,
34
+ ProcessExitError,
35
+ ProcessOutputError,
36
+ ProcessTimeoutError,
37
+ type ProcessService,
38
+ } from "./process.ts"
7
39
 
8
40
  export interface VcsService {
9
41
  readonly detect: (root: string) => Effect.Effect<VcsBackend | null>
10
- readonly isDirty: (root: string, vcs: VcsBackend) => Effect.Effect<boolean>
42
+ readonly isDirty: (
43
+ root: string,
44
+ vcs: VcsBackend,
45
+ ) => Effect.Effect<boolean, VcsError>
11
46
  readonly treeFingerprint: (
12
47
  root: string,
13
48
  vcs: VcsBackend,
14
- ) => Effect.Effect<string>
49
+ ) => Effect.Effect<string, VcsError>
15
50
  readonly ensureGitBranch: (
16
51
  root: string,
17
52
  slug: string,
18
53
  ) => Effect.Effect<string, VcsError>
19
- 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: (
20
61
  root: string,
21
62
  vcs: VcsBackend,
22
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,
23
74
  ) => Effect.Effect<string, VcsError>
24
75
  readonly setBookmarkAtTerminus: (
25
76
  root: string,
26
77
  slug: string,
27
- ) => Effect.Effect<void>
78
+ ) => Effect.Effect<void, VcsError>
28
79
  readonly runVerify: (
29
80
  root: string,
30
- commands: readonly string[],
81
+ blocks: readonly VerifyBlock[],
31
82
  timeoutMs: number,
32
83
  ) => Effect.Effect<{ ok: boolean; log: string }>
33
84
  }
34
85
 
35
86
  export class Vcs extends Context.Service<Vcs, VcsService>()("apnea/Vcs") {}
36
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
+
37
115
  function run(
38
116
  cmd: string,
39
117
  args: string[],
40
118
  cwd: string,
119
+ env?: NodeJS.ProcessEnv,
41
120
  ): { ok: boolean; stdout: string; stderr: string; code: number } {
42
121
  const r = spawnSync(cmd, args, {
43
122
  cwd,
44
123
  encoding: "utf8",
45
124
  maxBuffer: 10 * 1024 * 1024,
125
+ env: env === undefined ? undefined : { ...process.env, ...env },
46
126
  })
47
127
  return {
48
128
  ok: r.status === 0,
49
129
  stdout: (r.stdout ?? "").toString(),
50
- stderr: (r.stderr ?? "").toString(),
130
+ stderr: (r.stderr ?? r.error?.message ?? "").toString(),
51
131
  code: r.status ?? 1,
52
132
  }
53
133
  }
54
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
+
543
+ function verificationError(
544
+ error: unknown,
545
+ temporaryDirectory?: string,
546
+ ): string {
547
+ const message =
548
+ error instanceof Error ? `${error.name}: ${error.message}` : String(error)
549
+ return temporaryDirectory
550
+ ? message.replaceAll(
551
+ temporaryDirectory,
552
+ "[temporary verification directory]",
553
+ )
554
+ : message
555
+ }
556
+
557
+ const VERIFY_LOG_LIMIT = 10 * 1024 * 1024
558
+ const VERIFY_RESULT_RESERVE = 2_048
559
+ const VERIFY_WRAPPER_SOURCE = `exec 2>&1
560
+ exec "$1" -e "$2"
561
+ `
562
+ const VERIFY_DISPLAY_LIMIT_NOTICE = `verification log limit of ${VERIFY_LOG_LIMIT} bytes would be exceeded by the verification block display; block was not executed`
563
+ const VERIFY_LOG_LIMIT_NOTICE = `verification log limit of ${VERIFY_LOG_LIMIT} bytes reached; output was truncated and verification stopped`
564
+ const VERIFY_LIMIT_NOTICE_RESERVE =
565
+ 1 +
566
+ Math.max(
567
+ Buffer.byteLength(VERIFY_DISPLAY_LIMIT_NOTICE),
568
+ Buffer.byteLength(VERIFY_LOG_LIMIT_NOTICE),
569
+ )
570
+
571
+ export function utf8BytesAfterAppend(
572
+ usedBytes: number,
573
+ limitBytes: number,
574
+ text: string,
575
+ ): number | null {
576
+ const nextBytes = usedBytes + Buffer.byteLength(text)
577
+ return nextBytes <= limitBytes ? nextBytes : null
578
+ }
579
+
580
+ class VerificationLog {
581
+ readonly #chunks: string[] = []
582
+ readonly #contentLimit: number
583
+ #bytes = 0
584
+ #limited = false
585
+
586
+ constructor(readonly limit: number) {
587
+ this.#contentLimit = Math.max(0, limit - VERIFY_LIMIT_NOTICE_RESERVE)
588
+ }
589
+
590
+ get remaining(): number {
591
+ return this.#contentLimit - this.#bytes
592
+ }
593
+
594
+ canAppendBytes(bytes: number): boolean {
595
+ return bytes <= this.remaining
596
+ }
597
+
598
+ append(text: string): boolean {
599
+ const nextBytes = utf8BytesAfterAppend(
600
+ this.#bytes,
601
+ this.#contentLimit,
602
+ text,
603
+ )
604
+ if (nextBytes === null) return false
605
+ this.#chunks.push(text)
606
+ this.#bytes = nextBytes
607
+ return true
608
+ }
609
+
610
+ addLimitNotice(notice: string): void {
611
+ if (this.#limited) return
612
+ this.#limited = true
613
+ const previous = this.#chunks.at(-1)
614
+ if (this.#bytes > 0 && !previous?.endsWith("\n")) {
615
+ this.#chunks.push("\n")
616
+ this.#bytes += 1
617
+ }
618
+ this.#chunks.push(notice)
619
+ this.#bytes += Buffer.byteLength(notice)
620
+ }
621
+
622
+ toString(): string {
623
+ return this.#chunks.join("").trimEnd()
624
+ }
625
+ }
626
+
627
+ export function verifyBlockDisplayByteLength(block: VerifyBlock): number {
628
+ const source = block.source
629
+ const bodyEnd = source.endsWith("\n") ? source.length - 1 : source.length
630
+ let lineCount = 1
631
+ for (let index = 0; index < bodyEnd; index++) {
632
+ if (source.charCodeAt(index) === 10) lineCount += 1
633
+ }
634
+ const bodyBytes =
635
+ Buffer.byteLength(source) - (bodyEnd < source.length ? 1 : 0)
636
+ return (
637
+ Buffer.byteLength(`${block.interpreter} -e [verification block]\n`) +
638
+ bodyBytes +
639
+ lineCount * 2
640
+ )
641
+ }
642
+
643
+ type VerificationProcessResult = {
644
+ code: number
645
+ output: string
646
+ error?: string
647
+ }
648
+
649
+ function runVerificationProcess(
650
+ processService: ProcessService,
651
+ wrapper: string,
652
+ interpreter: VerifyBlock["interpreter"],
653
+ script: string,
654
+ cwd: string,
655
+ timeoutMs: number,
656
+ outputLimit: number,
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],
664
+ cwd,
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 }
671
+ }
672
+ const error = result.failure
673
+ if (error instanceof ProcessExitError) {
674
+ return {
675
+ code: error.exitCode,
676
+ output: `${error.stdout}${error.stderr}`,
677
+ }
678
+ }
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
+ }
685
+ }
686
+ if (error instanceof ProcessOutputError) {
687
+ return {
688
+ code: 1,
689
+ output: `${error.stdout}${error.stderr}`,
690
+ error: `verification output exceeded ${outputLimit} bytes`,
691
+ }
692
+ }
693
+ return {
694
+ code: 1,
695
+ output: "stdout" in error ? `${error.stdout}${error.stderr}` : "",
696
+ error: `verification process error: ${verificationError(error)}`,
697
+ }
698
+ })
699
+ }
700
+
55
701
  /** Drop .apnea/ runtime paths from VCS summaries (artifacts are allowed). */
56
702
  export function filterAppPaths(summary: string): string {
57
703
  return summary
@@ -61,24 +707,699 @@ export function filterAppPaths(summary: string): string {
61
707
  if (!t) return false
62
708
  // git porcelain: XY path
63
709
  if (/^.. /.test(line)) {
64
- const p = line.slice(3).replace(/^"|"$/g, "")
710
+ const p = line.slice(3).replace(/^"|"$/g, "").toLowerCase()
65
711
  return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
66
712
  }
67
713
  // jj summary often: M path / A path
68
714
  const m = t.match(/^[A-Z]+\s+(.+)$/)
69
715
  if (m) {
70
- const p = m[1]!
716
+ const p = m[1]!.toLowerCase()
71
717
  return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
72
718
  }
73
- return !t.includes(".apnea/")
719
+ return !t.toLowerCase().includes(".apnea/")
74
720
  })
75
721
  .join("\n")
76
722
  }
77
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
+
78
1398
  export const VcsLive = Layer.effect(
79
1399
  Vcs,
80
1400
  Effect.gen(function* () {
81
1401
  const fs = yield* FileSystem
1402
+ const processService = yield* Process
82
1403
 
83
1404
  const detect = (root: string): Effect.Effect<VcsBackend | null> =>
84
1405
  Effect.gen(function* () {
@@ -90,17 +1411,16 @@ export const VcsLive = Layer.effect(
90
1411
  const treeFingerprint = (
91
1412
  root: string,
92
1413
  vcs: VcsBackend,
93
- ): Effect.Effect<string> =>
94
- Effect.sync(() => {
95
- if (vcs === "jj") {
96
- const r = run("jj", ["diff", "--summary"], root)
97
- return filterAppPaths(r.stdout)
98
- }
99
- const r = run("git", ["status", "--porcelain"], root)
100
- return filterAppPaths(r.stdout)
1414
+ ): Effect.Effect<string, VcsError> =>
1415
+ Effect.gen(function* () {
1416
+ yield* rejectCaseFoldedApneaAlias(root)
1417
+ return yield* treeFingerprintWithCommand(root, vcs, run)
101
1418
  })
102
1419
 
103
- const isDirty = (root: string, vcs: VcsBackend): Effect.Effect<boolean> =>
1420
+ const isDirty = (
1421
+ root: string,
1422
+ vcs: VcsBackend,
1423
+ ): Effect.Effect<boolean, VcsError> =>
104
1424
  Effect.gen(function* () {
105
1425
  const fp = yield* treeFingerprint(root, vcs)
106
1426
  return fp.trim().length > 0
@@ -147,98 +1467,80 @@ export const VcsLive = Layer.effect(
147
1467
  return branch
148
1468
  })
149
1469
 
150
- const commitPhase = (
1470
+ const prepareCommit = (
151
1471
  root: string,
152
1472
  vcs: VcsBackend,
153
1473
  message: string,
154
- ): Effect.Effect<string, VcsError> =>
155
- Effect.gen(function* () {
156
- if (vcs === "jj") {
157
- const d = yield* Effect.sync(() =>
158
- 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
+ }),
159
1493
  )
160
- if (!d.ok) {
161
- return yield* new VcsError({
162
- message: d.stderr || d.stdout,
163
- command: "jj describe",
164
- })
165
- }
166
- const n = yield* Effect.sync(() => run("jj", ["new"], root))
167
- if (!n.ok) {
168
- return yield* new VcsError({
169
- message: n.stderr || n.stdout,
170
- command: "jj new",
171
- })
172
- }
173
- return "jj describe + new"
174
1494
  }
175
- const add = yield* Effect.sync(() => run("git", ["add", "-A"], root))
176
- if (!add.ok) {
177
- return yield* new VcsError({
178
- message: add.stderr,
179
- command: "git add -A",
180
- })
181
- }
182
- const c = yield* Effect.sync(() =>
183
- 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
+ }),
184
1502
  )
185
- if (!c.ok) {
186
- return yield* new VcsError({
187
- message: c.stderr || c.stdout,
188
- command: "git commit",
189
- })
190
- }
191
- return "git commit"
192
- })
1503
+ }
1504
+ return gitCompleteWithCommand(root, pending, run, mutate)
1505
+ }
193
1506
 
194
1507
  const setBookmarkAtTerminus = (
195
1508
  root: string,
196
1509
  slug: string,
197
- ): Effect.Effect<void> =>
198
- Effect.sync(() => {
1510
+ ): Effect.Effect<void, VcsError> =>
1511
+ Effect.gen(function* () {
199
1512
  const name = `apnea/${slug}`
200
1513
  const r = run("jj", ["bookmark", "set", name, "-r", "@-"], root)
201
1514
  if (!r.ok) {
202
- 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
+ }
203
1527
  }
204
1528
  })
205
1529
 
206
1530
  const runVerify = (
207
1531
  root: string,
208
- commands: readonly string[],
1532
+ blocks: readonly VerifyBlock[],
209
1533
  timeoutMs: number,
210
1534
  ): Effect.Effect<{ ok: boolean; log: string }> =>
211
- Effect.sync(() => {
212
- const lines: string[] = []
213
- for (const cmd of commands) {
214
- lines.push(`$ ${cmd}`)
215
- const r = spawnSync("bash", ["-lc", cmd], {
216
- cwd: root,
217
- encoding: "utf8",
218
- timeout: timeoutMs,
219
- maxBuffer: 10 * 1024 * 1024,
220
- })
221
- const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trimEnd()
222
- if (out) lines.push(out)
223
- lines.push(`exit=${r.status ?? 1}`)
224
- if (r.error) {
225
- lines.push(String(r.error))
226
- return { ok: false, log: lines.join("\n") }
227
- }
228
- if (r.status !== 0) {
229
- return { ok: false, log: lines.join("\n") }
230
- }
231
- lines.push("")
232
- }
233
- return { ok: true, log: lines.join("\n") }
234
- })
1535
+ runVerifyWithProcess(root, blocks, timeoutMs, processService)
235
1536
 
236
1537
  return Vcs.of({
237
1538
  detect,
238
1539
  isDirty,
239
1540
  treeFingerprint,
240
1541
  ensureGitBranch,
241
- commitPhase,
1542
+ prepareCommit,
1543
+ completeCommit,
242
1544
  setBookmarkAtTerminus,
243
1545
  runVerify,
244
1546
  })