@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
@@ -16,8 +16,14 @@ import {
16
16
  import { ok, type ToolResult } from "../result.ts"
17
17
  import { Config } from "../services/config.ts"
18
18
  import { FileSystem } from "../services/file-system.ts"
19
- import { RunStore } from "../services/run-store.ts"
20
- import { Vcs } from "../services/vcs.ts"
19
+ import { RunStore, type RunStoreService } from "../services/run-store.ts"
20
+ import {
21
+ Vcs,
22
+ type VcsService,
23
+ withTransactionTrailer,
24
+ withoutTransactionTrailer,
25
+ } from "../services/vcs.ts"
26
+ import type { RunState } from "../domain/types.ts"
21
27
 
22
28
  export type CommitParams = {
23
29
  message?: string
@@ -28,6 +34,18 @@ export type CommitParams = {
28
34
  /**
29
35
  * Require APPROVED code review, run phase package verify commands,
30
36
  * jj/git commit, advance phase. Refusals are tagged failures only.
37
+ *
38
+ * The commit itself is a crash-recoverable transaction:
39
+ *
40
+ * gates → verify.log → prepare → save pending_commit (durable point)
41
+ * → complete or recognize → bookmark (jj, final phase) → advance + clear
42
+ *
43
+ * Once `pending_commit` is saved, cancellation does NOT undo the
44
+ * transaction — the prepared anchor is durable and a later commit call
45
+ * resumes or recognizes it. A call that loads an existing `pending_commit`
46
+ * skips the gates and verification entirely and resumes only that
47
+ * transaction; retry params conflicting with the persisted message or
48
+ * `--done` value are refused, not silently ignored.
31
49
  */
32
50
  export const commitWorkflow = (
33
51
  params: CommitParams,
@@ -46,6 +64,16 @@ export const commitWorkflow = (
46
64
  return yield* allowed.failure
47
65
  }
48
66
 
67
+ if (state.pending_commit) {
68
+ return yield* resumePendingCommit({
69
+ params,
70
+ root,
71
+ state,
72
+ store,
73
+ vcs,
74
+ })
75
+ }
76
+
49
77
  const reviewRel = state.current_code_review
50
78
  if (!reviewRel) {
51
79
  return yield* new GateRefused({
@@ -55,10 +83,18 @@ export const commitWorkflow = (
55
83
  }
56
84
 
57
85
  const reviewAbs = abs(reviewRel, root)
58
- const reviewPresent = yield* fs.exists(reviewAbs)
86
+ const reviewPresent = yield* fs.projectPathExists(root, reviewAbs)
59
87
  let fm: ReturnType<typeof parseFrontMatter> = null
60
88
  if (reviewPresent) {
61
- const reviewText = yield* fs.readFile(reviewAbs)
89
+ const reviewText = yield* fs.readProjectFile(root, reviewAbs).pipe(
90
+ Effect.mapError(
91
+ (error) =>
92
+ new ArtifactInvalid({
93
+ artifact: reviewRel,
94
+ message: error.message,
95
+ }),
96
+ ),
97
+ )
62
98
  fm = parseFrontMatter(reviewText)
63
99
  }
64
100
  const verdict = asVerdict(fm?.verdict)
@@ -80,7 +116,7 @@ export const commitWorkflow = (
80
116
  root,
81
117
  )
82
118
  const pkgAbs = abs(pkgRel, root)
83
- const pkgPresent = yield* fs.exists(pkgAbs)
119
+ const pkgPresent = yield* fs.projectPathExists(root, pkgAbs)
84
120
  if (!pkgPresent) {
85
121
  return yield* new GateRefused({
86
122
  gate: "commit",
@@ -88,7 +124,14 @@ export const commitWorkflow = (
88
124
  details: { package: pkgRel },
89
125
  })
90
126
  }
91
- const pkgText = yield* fs.readFile(pkgAbs)
127
+ const pkgText = yield* fs
128
+ .readProjectFile(root, pkgAbs)
129
+ .pipe(
130
+ Effect.mapError(
131
+ (error) =>
132
+ new ArtifactInvalid({ artifact: pkgRel, message: error.message }),
133
+ ),
134
+ )
92
135
  const blocks = extractVerifyBlocks(pkgText)
93
136
  if (!blocks.length) {
94
137
  return yield* new ArtifactInvalid({
@@ -102,8 +145,7 @@ export const commitWorkflow = (
102
145
  const verify = yield* vcs.runVerify(root, blocks, verifyTimeout)
103
146
 
104
147
  const vlog = path.join(path.dirname(reviewAbs), "verify.log")
105
- yield* fs.mkdir(path.dirname(vlog), { recursive: true })
106
- yield* fs.writeFile(vlog, `${verify.log}\n`)
148
+ yield* fs.writeProjectFile(root, vlog, `${verify.log}\n`)
107
149
 
108
150
  if (!verify.ok) {
109
151
  return yield* new VerifyFailed({
@@ -118,9 +160,162 @@ export const commitWorkflow = (
118
160
  params.message?.trim() ||
119
161
  `feat: apnea phase ${state.phase_index} (${state.slug})`
120
162
 
121
- const detail = yield* vcs.commitPhase(root, state.vcs, message)
163
+ const prepared = yield* vcs.prepareCommit(root, state.vcs, message)
164
+
165
+ // Durable point. From here the transaction survives crashes and
166
+ // cancellation: the anchor below is everything completion needs to
167
+ // recognize or create the commit exactly once.
168
+ const noRemainingPhases = params.no_remaining_phases === true
169
+ const verifyLogRel = rel(vlog, root)
170
+ state.pending_commit =
171
+ prepared.backend === "git"
172
+ ? {
173
+ id: prepared.id,
174
+ backend: prepared.backend,
175
+ phase_index: state.phase_index,
176
+ message: prepared.message,
177
+ no_remaining_phases: noRemainingPhases,
178
+ verify_log: verifyLogRel,
179
+ branch: prepared.branch,
180
+ parent_commit: prepared.parent_commit,
181
+ tree_id: prepared.tree_id,
182
+ }
183
+ : {
184
+ id: prepared.id,
185
+ backend: prepared.backend,
186
+ phase_index: state.phase_index,
187
+ message: prepared.message,
188
+ no_remaining_phases: noRemainingPhases,
189
+ verify_log: verifyLogRel,
190
+ change_id: prepared.change_id,
191
+ content_fingerprint: prepared.content_fingerprint,
192
+ }
193
+ yield* store.save(state, root)
194
+
195
+ const committedId = yield* vcs.completeCommit(
196
+ root,
197
+ state.vcs,
198
+ state.pending_commit,
199
+ )
200
+
201
+ return yield* advanceAndSave({
202
+ state,
203
+ store,
204
+ vcs,
205
+ root,
206
+ noRemainingPhases,
207
+ committedId,
208
+ recovered: false,
209
+ transactionId: state.pending_commit.id,
210
+ verifyLog: verifyLogRel,
211
+ })
212
+ })
213
+
214
+ type ResumeArgs = {
215
+ params: CommitParams
216
+ root: string
217
+ state: RunState
218
+ store: RunStoreService
219
+ vcs: VcsService
220
+ }
221
+
222
+ /**
223
+ * Resume the durable transaction only: no gates, no verification.
224
+ * Explicit retry params conflicting with the persisted values are refused.
225
+ */
226
+ function resumePendingCommit({
227
+ params,
228
+ root,
229
+ state,
230
+ store,
231
+ vcs,
232
+ }: ResumeArgs): Effect.Effect<ToolResult, AppError> {
233
+ return Effect.gen(function* () {
234
+ const pending = state.pending_commit!
235
+ const requestedMessage = params.message?.trim()
236
+ // Compare against the exact trailer-augmented form the transaction will
237
+ // write, not a stripped persisted message: stripping only removes the
238
+ // LAST trailer line, so a user message that legitimately ends with a
239
+ // trailer-shaped line would otherwise refuse an identical plain retry.
240
+ if (
241
+ requestedMessage !== undefined &&
242
+ requestedMessage !== pending.message &&
243
+ withTransactionTrailer(requestedMessage, pending.id) !== pending.message
244
+ ) {
245
+ return yield* new GateRefused({
246
+ gate: "commit",
247
+ message:
248
+ "conflicting retry: this call already has a durable commit transaction with a different message",
249
+ details: {
250
+ transaction: pending.id,
251
+ persisted_message: withoutTransactionTrailer(pending.message),
252
+ requested_message: requestedMessage,
253
+ },
254
+ })
255
+ }
256
+ if (
257
+ params.no_remaining_phases !== undefined &&
258
+ params.no_remaining_phases !== pending.no_remaining_phases
259
+ ) {
260
+ return yield* new GateRefused({
261
+ gate: "commit",
262
+ message:
263
+ "conflicting retry: this call already has a durable commit transaction with a different no_remaining_phases value",
264
+ details: {
265
+ transaction: pending.id,
266
+ persisted_no_remaining_phases: pending.no_remaining_phases,
267
+ requested_no_remaining_phases: params.no_remaining_phases,
268
+ },
269
+ })
270
+ }
271
+
272
+ const committedId = yield* vcs.completeCommit(root, state.vcs, pending)
273
+
274
+ return yield* advanceAndSave({
275
+ state,
276
+ store,
277
+ vcs,
278
+ root,
279
+ noRemainingPhases: pending.no_remaining_phases,
280
+ committedId,
281
+ recovered: true,
282
+ transactionId: pending.id,
283
+ verifyLog: pending.verify_log,
284
+ })
285
+ })
286
+ }
287
+
288
+ type AdvanceArgs = {
289
+ state: RunState
290
+ store: RunStoreService
291
+ vcs: VcsService
292
+ root: string
293
+ noRemainingPhases: boolean
294
+ committedId: string
295
+ recovered: boolean
296
+ transactionId: string
297
+ /** Repo-relative verify.log path captured before the phase advanced. */
298
+ verifyLog: string
299
+ }
122
300
 
123
- if (params.no_remaining_phases) {
301
+ /**
302
+ * Advance the run after completion. A bookmark failure propagates BEFORE any
303
+ * state mutation is saved, so `pending_commit` stays durable and the
304
+ * transaction remains resumable on the next call.
305
+ */
306
+ function advanceAndSave({
307
+ state,
308
+ store,
309
+ vcs,
310
+ root,
311
+ noRemainingPhases,
312
+ committedId,
313
+ recovered,
314
+ transactionId,
315
+ verifyLog,
316
+ }: AdvanceArgs): Effect.Effect<ToolResult, AppError> {
317
+ return Effect.gen(function* () {
318
+ if (noRemainingPhases) {
124
319
  state.step = "finishing"
125
320
  if (state.vcs === "jj") {
126
321
  yield* vcs.setBookmarkAtTerminus(root, state.slug)
@@ -132,13 +327,18 @@ export const commitWorkflow = (
132
327
  state.current_code_review = null
133
328
  }
134
329
  state.last_error = null
330
+ state.pending_commit = null
135
331
  yield* store.save(state, root)
136
332
 
333
+ const prefix = recovered
334
+ ? `committed phase (recovered transaction ${transactionId})`
335
+ : "committed phase"
137
336
  return ok(
138
- `committed phase; step → ${state.step}`,
337
+ `${prefix}; step → ${state.step}`,
139
338
  {
140
- vcs_detail: detail,
141
- verify_log: rel(vlog, root),
339
+ vcs_detail: `${state.vcs} commit ${committedId}`,
340
+ transaction: transactionId,
341
+ verify_log: verifyLog,
142
342
  step: state.step,
143
343
  phase_index: state.phase_index,
144
344
  next:
@@ -149,3 +349,4 @@ export const commitWorkflow = (
149
349
  nextAfter(state.step),
150
350
  )
151
351
  })
352
+ }