@markjaquith/agency 3.2.8 → 3.2.10

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.
@@ -1,4 +1,4 @@
1
- import { Data, Effect } from "effect"
1
+ import { Cause, Data, Effect, Exit, Option } from "effect"
2
2
  import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
3
3
  import { dirname, join, relative } from "node:path"
4
4
  import {
@@ -16,25 +16,33 @@ class LifecycleTransactionError extends Data.TaggedError(
16
16
  readonly cause?: unknown
17
17
  }> {}
18
18
 
19
- export interface TransactionStep {
19
+ export interface TransactionStep<R = never> {
20
20
  readonly label: string
21
- readonly preflight?: () => Promise<void>
22
- readonly apply: () => Promise<void>
23
- readonly rollback?: () => Promise<void>
24
- readonly finalize?: () => Promise<void>
21
+ readonly preflight?: Effect.Effect<void, unknown, R>
22
+ readonly apply: Effect.Effect<void, unknown, R>
23
+ readonly rollback?: Effect.Effect<void, unknown, R>
24
+ readonly finalize?: Effect.Effect<void, unknown, R>
25
25
  readonly manualRecovery?: string
26
26
  }
27
27
 
28
+ export const transactionEffect = <A>(run: () => PromiseLike<A>) =>
29
+ Effect.uninterruptible(
30
+ Effect.tryPromise({
31
+ try: () => Promise.resolve(run()),
32
+ catch: (cause) => cause,
33
+ }),
34
+ )
35
+
28
36
  export const pathMustNotExistStep = (
29
37
  root: string,
30
38
  path: string,
31
39
  message: string,
32
40
  ): TransactionStep => ({
33
41
  label: `verify ${relative(root, path)} is available`,
34
- preflight: async () => {
42
+ preflight: transactionEffect(async () => {
35
43
  if (await exists(path)) throw new Error(message)
36
- },
37
- apply: async () => {},
44
+ }),
45
+ apply: Effect.void,
38
46
  })
39
47
 
40
48
  interface DocumentWrite {
@@ -43,13 +51,13 @@ interface DocumentWrite {
43
51
  readonly create?: boolean
44
52
  }
45
53
 
46
- interface TransactionPlan {
54
+ interface TransactionPlan<R> {
47
55
  readonly root: string
48
56
  readonly preconditions?: readonly {
49
57
  readonly path: string
50
58
  readonly revision: string
51
59
  }[]
52
- readonly steps: readonly TransactionStep[]
60
+ readonly steps: readonly TransactionStep<R>[]
53
61
  }
54
62
 
55
63
  const exists = async (path: string) => {
@@ -103,7 +111,7 @@ export const documentWriteStep = (
103
111
 
104
112
  return {
105
113
  label,
106
- preflight: async () => {
114
+ preflight: transactionEffect(async () => {
107
115
  for (const write of staged) {
108
116
  const targetExists = await exists(write.path)
109
117
  if (write.create === true && targetExists)
@@ -115,8 +123,8 @@ export const documentWriteStep = (
115
123
  `Document does not exist: ${relative(root, write.path)}`,
116
124
  )
117
125
  }
118
- },
119
- apply: async () => {
126
+ }),
127
+ apply: transactionEffect(async () => {
120
128
  await mkdir(stagingDirectory)
121
129
  for (const write of staged) await Bun.write(write.stage, write.content)
122
130
  try {
@@ -156,11 +164,11 @@ export const documentWriteStep = (
156
164
  }
157
165
  throw cause
158
166
  }
159
- },
160
- rollback,
161
- finalize: async () => {
167
+ }),
168
+ rollback: transactionEffect(rollback),
169
+ finalize: transactionEffect(async () => {
162
170
  await rm(stagingDirectory, { recursive: true, force: true })
163
- },
171
+ }),
164
172
  manualRecovery: `Inspect ${relative(root, stagingDirectory)} for staged documents and backups`,
165
173
  }
166
174
  }
@@ -173,146 +181,157 @@ export const directoryMoveStep = (
173
181
  let createdParent = false
174
182
  return {
175
183
  label: `move ${relative(root, from)} to ${relative(root, to)}`,
176
- preflight: async () => {
184
+ preflight: transactionEffect(async () => {
177
185
  if (!(await exists(from)))
178
186
  throw new Error(`Move source does not exist: ${relative(root, from)}`)
179
187
  if (await exists(to))
180
188
  throw new Error(
181
189
  `Move destination already exists: ${relative(root, to)}`,
182
190
  )
183
- },
184
- apply: async () => {
191
+ }),
192
+ apply: transactionEffect(async () => {
185
193
  const parent = dirname(to)
186
194
  if (!(await exists(parent))) {
187
195
  await mkdir(parent, { recursive: true })
188
196
  createdParent = true
189
197
  }
190
198
  await rename(from, to)
191
- },
192
- rollback: async () => {
199
+ }),
200
+ rollback: transactionEffect(async () => {
193
201
  await rename(to, from)
194
202
  if (createdParent) await rm(dirname(to), { recursive: true, force: true })
195
- },
203
+ }),
196
204
  manualRecovery: `Move ${relative(root, to)} back to ${relative(root, from)}`,
197
205
  }
198
206
  }
199
207
 
200
- export const runLifecycleTransaction = ({
208
+ export const runLifecycleTransaction = <R>({
201
209
  root,
202
210
  preconditions = [],
203
211
  steps,
204
- }: TransactionPlan) =>
205
- Effect.tryPromise({
206
- try: async () => {
207
- const lockPath = join(root, ".agency-graph-mutation.lock")
208
- let lock: Awaited<ReturnType<typeof open>>
209
- try {
210
- lock = await open(lockPath, "wx")
211
- } catch (cause) {
212
- throw new LifecycleTransactionError({
213
- message:
214
- "Another graph mutation is in progress; wait for it to finish and retry",
215
- completed: [],
216
- rolledBack: [],
217
- manualRecovery: [],
218
- cause,
219
- })
220
- }
221
-
222
- const completed: TransactionStep[] = []
223
- const rolledBack: string[] = []
224
- try {
225
- for (const precondition of preconditions) {
226
- const content = await Bun.file(precondition.path).text()
227
- const currentRevision = documentRevision(content)
228
- if (currentRevision !== precondition.revision) {
229
- throw new RevisionConflictError({
230
- path: relative(root, precondition.path),
231
- expectedRevision: precondition.revision,
232
- currentRevision,
233
- message: `Revision conflict for ${relative(root, precondition.path)}`,
234
- })
235
- }
236
- }
237
- for (const step of steps) await step.preflight?.()
238
- for (const step of steps) {
239
- await step.apply()
240
- completed.push(step)
241
- }
242
- const cleanup = await Promise.allSettled(
243
- completed.map((step) => step.finalize?.() ?? Promise.resolve()),
244
- )
245
- const cleanupFailures = cleanup.filter(
246
- (result) => result.status === "rejected",
247
- )
248
- if (cleanupFailures.length > 0) {
249
- throw new LifecycleTransactionError({
212
+ }: TransactionPlan<R>) =>
213
+ Effect.uninterruptibleMask((restore) => {
214
+ const lockPath = join(root, ".agency-graph-mutation.lock")
215
+ return Effect.acquireUseRelease(
216
+ Effect.tryPromise({
217
+ try: () => open(lockPath, "wx"),
218
+ catch: (cause) =>
219
+ new LifecycleTransactionError({
250
220
  message:
251
- "Lifecycle mutation completed, but transaction artifacts require manual cleanup",
252
- completed: completed.map((step) => step.label),
253
- rolledBack: [],
254
- manualRecovery: completed.flatMap((step) =>
255
- step.finalize && step.manualRecovery ? [step.manualRecovery] : [],
256
- ),
257
- cause: new AggregateError(
258
- cleanupFailures.map((result) =>
259
- result.status === "rejected" ? result.reason : undefined,
260
- ),
261
- ),
262
- })
263
- }
264
- } catch (cause) {
265
- if (
266
- cause instanceof LifecycleTransactionError ||
267
- cause instanceof RevisionConflictError
268
- )
269
- throw cause
270
- const rollbackErrors: unknown[] = []
271
- for (const step of [...completed].reverse()) {
272
- if (!step.rollback) continue
273
- try {
274
- await step.rollback()
275
- rolledBack.push(step.label)
276
- await step.finalize?.()
277
- } catch (error) {
278
- rollbackErrors.push(error)
279
- }
280
- }
281
- const manualRecovery = completed
282
- .filter(
283
- (step) =>
284
- !rolledBack.includes(step.label) &&
285
- step.manualRecovery !== undefined,
286
- )
287
- .map((step) => step.manualRecovery!)
288
- throw new LifecycleTransactionError({
289
- message:
290
- completed.length === 0
291
- ? `Lifecycle mutation failed before changes were applied: ${cause instanceof Error ? cause.message : String(cause)}`
292
- : rollbackErrors.length
293
- ? `Lifecycle mutation failed and rollback requires manual recovery: ${cause instanceof Error ? cause.message : String(cause)}`
294
- : `Lifecycle mutation failed; completed changes were rolled back: ${cause instanceof Error ? cause.message : String(cause)}`,
295
- completed: completed.map((step) => step.label),
296
- rolledBack,
297
- manualRecovery,
298
- cause: rollbackErrors.length
299
- ? new AggregateError([cause, ...rollbackErrors])
300
- : cause,
301
- })
302
- } finally {
303
- await lock.close().catch(() => undefined)
304
- await rm(lockPath, { force: true }).catch(() => undefined)
305
- }
306
- },
307
- catch: (cause) =>
308
- cause instanceof LifecycleTransactionError ||
309
- cause instanceof RevisionConflictError
310
- ? cause
311
- : new LifecycleTransactionError({
312
- message: "Lifecycle mutation failed before changes were applied",
221
+ "Another graph mutation is in progress; wait for it to finish and retry",
313
222
  completed: [],
314
223
  rolledBack: [],
315
224
  manualRecovery: [],
316
225
  cause,
317
226
  }),
227
+ }),
228
+ () => {
229
+ const completed: TransactionStep<R>[] = []
230
+ const rolledBack: string[] = []
231
+ const execute = Effect.gen(function* () {
232
+ for (const precondition of preconditions) {
233
+ const content = yield* restore(
234
+ transactionEffect(() => Bun.file(precondition.path).text()),
235
+ )
236
+ const currentRevision = documentRevision(content as string)
237
+ if (currentRevision !== precondition.revision) {
238
+ return yield* new RevisionConflictError({
239
+ path: relative(root, precondition.path),
240
+ expectedRevision: precondition.revision,
241
+ currentRevision,
242
+ message: `Revision conflict for ${relative(root, precondition.path)}`,
243
+ })
244
+ }
245
+ }
246
+ for (const step of steps) {
247
+ if (step.preflight) yield* restore(step.preflight)
248
+ }
249
+ for (const step of steps) {
250
+ yield* restore(
251
+ Effect.uninterruptibleMask((restoreStep) =>
252
+ restoreStep(step.apply).pipe(
253
+ Effect.tap(() => Effect.sync(() => completed.push(step))),
254
+ ),
255
+ ),
256
+ )
257
+ }
258
+ const cleanup = yield* Effect.forEach(
259
+ completed,
260
+ (step) => Effect.exit(step.finalize ?? Effect.void),
261
+ { concurrency: "unbounded" },
262
+ )
263
+ const cleanupFailures = cleanup.filter(Exit.isFailure)
264
+ if (cleanupFailures.length > 0) {
265
+ return yield* new LifecycleTransactionError({
266
+ message:
267
+ "Lifecycle mutation completed, but transaction artifacts require manual cleanup",
268
+ completed: completed.map((step) => step.label),
269
+ rolledBack: [],
270
+ manualRecovery: completed.flatMap((step) =>
271
+ step.finalize && step.manualRecovery
272
+ ? [step.manualRecovery]
273
+ : [],
274
+ ),
275
+ cause: new AggregateError(
276
+ cleanupFailures.map((result) => Cause.squash(result.cause)),
277
+ ),
278
+ })
279
+ }
280
+ })
281
+
282
+ return execute.pipe(
283
+ Effect.catchAllCause((cause) =>
284
+ Effect.gen(function* () {
285
+ const failure = Option.getOrUndefined(Cause.failureOption(cause))
286
+ if (failure instanceof LifecycleTransactionError)
287
+ return yield* failure
288
+ const rollbackErrors: unknown[] = []
289
+ for (const step of [...completed].reverse()) {
290
+ if (!step.rollback) continue
291
+ const rollback = yield* Effect.exit(
292
+ step.rollback.pipe(
293
+ Effect.zipRight(step.finalize ?? Effect.void),
294
+ ),
295
+ )
296
+ if (Exit.isSuccess(rollback)) {
297
+ rolledBack.push(step.label)
298
+ } else {
299
+ rollbackErrors.push(Cause.squash(rollback.cause))
300
+ }
301
+ }
302
+ if (Cause.isInterruptedOnly(cause))
303
+ return yield* Effect.failCause(cause as Cause.Cause<never>)
304
+ const manualRecovery = completed
305
+ .filter(
306
+ (step) =>
307
+ !rolledBack.includes(step.label) &&
308
+ step.manualRecovery !== undefined,
309
+ )
310
+ .map((step) => step.manualRecovery!)
311
+ if (failure instanceof RevisionConflictError)
312
+ return yield* failure
313
+ return yield* new LifecycleTransactionError({
314
+ message:
315
+ completed.length === 0
316
+ ? `Lifecycle mutation failed before changes were applied: ${failure instanceof Error ? failure.message : String(failure)}`
317
+ : rollbackErrors.length
318
+ ? `Lifecycle mutation failed and rollback requires manual recovery: ${failure instanceof Error ? failure.message : String(failure)}`
319
+ : `Lifecycle mutation failed; completed changes were rolled back: ${failure instanceof Error ? failure.message : String(failure)}`,
320
+ completed: completed.map((step) => step.label),
321
+ rolledBack,
322
+ manualRecovery,
323
+ cause: rollbackErrors.length
324
+ ? new AggregateError([Cause.squash(cause), ...rollbackErrors])
325
+ : Cause.squash(cause),
326
+ })
327
+ }),
328
+ ),
329
+ )
330
+ },
331
+ (lock) =>
332
+ Effect.promise(async () => {
333
+ await lock.close().catch(() => undefined)
334
+ await rm(lockPath, { force: true }).catch(() => undefined)
335
+ }),
336
+ )
318
337
  })
@@ -23,6 +23,7 @@ import { archivedPhaseDirectory } from "../workbase/archive"
23
23
  import {
24
24
  documentWriteStep,
25
25
  runLifecycleTransaction,
26
+ transactionEffect,
26
27
  type TransactionStep,
27
28
  } from "./LifecycleTransaction"
28
29
  import { withWorktreeLocks } from "./WorktreeLock"
@@ -295,7 +296,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
295
296
  }
296
297
  steps.push({
297
298
  label: `move and repair code for ${taskId}/${firstPhaseId}`,
298
- preflight: async () => {
299
+ preflight: transactionEffect(async () => {
299
300
  for (const entry of await readdir(oldCodePath)) {
300
301
  if (!checkoutAliases.includes(entry))
301
302
  throw new Error(
@@ -339,8 +340,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
339
340
  `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
340
341
  )
341
342
  }
342
- },
343
- apply: async () => {
343
+ }),
344
+ apply: transactionEffect(async () => {
344
345
  await mkdir(firstDirectory, { recursive: true })
345
346
  await rename(oldCodePath, firstCodePath)
346
347
  try {
@@ -351,12 +352,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
351
352
  await rm(firstDirectory, { recursive: true, force: true })
352
353
  throw cause
353
354
  }
354
- },
355
- rollback: async () => {
355
+ }),
356
+ rollback: transactionEffect(async () => {
356
357
  await rename(firstCodePath, oldCodePath)
357
358
  await repair(oldCodePath)
358
359
  await rm(firstDirectory, { recursive: true, force: true })
359
- },
360
+ }),
360
361
  manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
361
362
  })
362
363
  }