@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.
- package/package.json +1 -1
- package/src/services/ArchiveService.ts +80 -86
- package/src/services/FileSystemService.test.ts +81 -0
- package/src/services/FileSystemService.ts +29 -7
- package/src/services/GraphService.test.ts +11 -0
- package/src/services/GraphService.ts +1 -1
- package/src/services/LifecycleTransaction.test.ts +50 -12
- package/src/services/LifecycleTransaction.ts +152 -133
- package/src/services/PhaseService.ts +7 -6
- package/src/services/RepositoryService.ts +150 -115
- package/src/services/ReviewService.ts +30 -35
- package/src/services/TaskService.ts +23 -21
- package/src/services/WorkbaseService.test.ts +15 -0
- package/src/services/WorkbaseService.ts +10 -4
|
@@ -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?:
|
|
22
|
-
readonly apply:
|
|
23
|
-
readonly rollback?:
|
|
24
|
-
readonly finalize?:
|
|
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:
|
|
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.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
"
|
|
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
|
}
|