@markjaquith/agency 3.2.9 → 3.2.11

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,5 +1,5 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
- import { Data, Effect, Either } from "effect"
2
+ import { Cause, Data, Effect, Either, Exit } from "effect"
3
3
  import { join, resolve } from "node:path"
4
4
  import { cp, lstat, realpath, rename, rm } from "node:fs/promises"
5
5
  import { FileSystemService } from "./FileSystemService"
@@ -9,6 +9,7 @@ import {
9
9
  directoryMoveStep,
10
10
  documentWriteStep,
11
11
  runLifecycleTransaction,
12
+ transactionEffect,
12
13
  type TransactionStep,
13
14
  } from "./LifecycleTransaction"
14
15
  import {
@@ -322,14 +323,13 @@ const assertRemovable = (
322
323
  }
323
324
  })
324
325
 
325
- const effectPreflightStep = (
326
+ const effectPreflightStep = <R>(
326
327
  label: string,
327
- check: Effect.Effect<void, unknown, any>,
328
- ): TransactionStep => ({
328
+ check: Effect.Effect<void, unknown, R>,
329
+ ): TransactionStep<R> => ({
329
330
  label,
330
- preflight: () =>
331
- Effect.runPromise(check as Effect.Effect<void, unknown, never>),
332
- apply: async () => undefined,
331
+ preflight: check,
332
+ apply: Effect.void,
333
333
  })
334
334
 
335
335
  const deleteAfterMoveStep = (
@@ -338,7 +338,7 @@ const deleteAfterMoveStep = (
338
338
  to: string,
339
339
  ): TransactionStep => ({
340
340
  ...directoryMoveStep(root, from, to),
341
- finalize: () => rm(to, { recursive: true, force: true }),
341
+ finalize: transactionEffect(() => rm(to, { recursive: true, force: true })),
342
342
  manualRecovery: `Remove ${to} or move it back to ${from}`,
343
343
  })
344
344
 
@@ -348,7 +348,7 @@ const replaceWithMoveStep = (
348
348
  backup: string,
349
349
  ): TransactionStep => ({
350
350
  label: `replace ${current} with ${replacement}`,
351
- preflight: async () => {
351
+ preflight: transactionEffect(async () => {
352
352
  await lstat(current)
353
353
  await lstat(replacement)
354
354
  try {
@@ -363,8 +363,8 @@ const replaceWithMoveStep = (
363
363
  )
364
364
  throw cause
365
365
  }
366
- },
367
- apply: async () => {
366
+ }),
367
+ apply: transactionEffect(async () => {
368
368
  await rename(current, backup)
369
369
  try {
370
370
  await rename(replacement, current)
@@ -372,12 +372,14 @@ const replaceWithMoveStep = (
372
372
  await rename(backup, current)
373
373
  throw cause
374
374
  }
375
- },
376
- rollback: async () => {
375
+ }),
376
+ rollback: transactionEffect(async () => {
377
377
  await rename(current, replacement)
378
378
  await rename(backup, current)
379
- },
380
- finalize: () => rm(backup, { recursive: true, force: true }),
379
+ }),
380
+ finalize: transactionEffect(() =>
381
+ rm(backup, { recursive: true, force: true }),
382
+ ),
381
383
  manualRecovery: `Restore ${backup} to ${current}`,
382
384
  })
383
385
 
@@ -386,26 +388,24 @@ const runGit = (
386
388
  args: readonly string[],
387
389
  label: string,
388
390
  ) =>
389
- Effect.runPromise(
390
- fs
391
- .runCommand(["git", ...args], { captureOutput: true })
392
- .pipe(
393
- Effect.flatMap((result) =>
394
- result.exitCode === 0
395
- ? Effect.void
396
- : Effect.fail(
397
- new Error(
398
- `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
399
- ),
391
+ fs
392
+ .runCommand(["git", ...args], { captureOutput: true })
393
+ .pipe(
394
+ Effect.flatMap((result) =>
395
+ result.exitCode === 0
396
+ ? Effect.void
397
+ : Effect.fail(
398
+ new Error(
399
+ `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
400
400
  ),
401
- ),
402
- ) as Effect.Effect<void, unknown, never>,
403
- )
401
+ ),
402
+ ),
403
+ )
404
404
 
405
405
  const runTransaction = (
406
406
  state: Effect.Effect.Success<ReturnType<typeof configState>>,
407
407
  config: WorkbaseConfig,
408
- steps: readonly TransactionStep[],
408
+ steps: readonly TransactionStep<any>[],
409
409
  ) =>
410
410
  runLifecycleTransaction({
411
411
  root: state.root,
@@ -418,7 +418,11 @@ const runTransaction = (
418
418
  ],
419
419
  }).pipe(
420
420
  Effect.mapError(
421
- (cause) => new RepositoryError({ message: cause.message, cause }),
421
+ (cause) =>
422
+ new RepositoryError({
423
+ message: cause instanceof Error ? cause.message : String(cause),
424
+ cause,
425
+ }),
422
426
  ),
423
427
  )
424
428
 
@@ -785,7 +789,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
785
789
  const workspacePaths = worktrees.map((workspace) => workspace.path)
786
790
  const repair = (gitDirectory: string) =>
787
791
  workspacePaths.length === 0
788
- ? Promise.resolve()
792
+ ? Effect.void
789
793
  : runGit(
790
794
  fs,
791
795
  [
@@ -797,98 +801,122 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
797
801
  ],
798
802
  "Failed to repair Git worktrees",
799
803
  )
800
- const rollbackMigration = async () => {
804
+ const rollbackMigration = Effect.gen(function* () {
801
805
  const errors: unknown[] = []
806
+ const attempt = (effect: Effect.Effect<void, unknown, any>) =>
807
+ effect.pipe(
808
+ Effect.exit,
809
+ Effect.tap((exit) => {
810
+ if (Exit.isFailure(exit))
811
+ errors.push(Cause.squash(exit.cause))
812
+ }),
813
+ )
802
814
  if (metadataMoved) {
803
- try {
804
- await rename(metadataBackup, sourceWorktrees)
805
- metadataMoved = false
806
- await repair(commonDirectory)
807
- } catch (error) {
808
- errors.push(error)
809
- }
815
+ yield* attempt(
816
+ transactionEffect(async () => {
817
+ await rename(metadataBackup, sourceWorktrees)
818
+ metadataMoved = false
819
+ }).pipe(Effect.zipRight(repair(commonDirectory))),
820
+ )
810
821
  }
811
822
  if (cloneInstalled) {
812
- try {
813
- await rename(repository.path, staging)
814
- cloneInstalled = false
815
- } catch (error) {
816
- errors.push(error)
817
- }
823
+ yield* attempt(
824
+ transactionEffect(async () => {
825
+ await rename(repository.path, staging)
826
+ cloneInstalled = false
827
+ }),
828
+ )
818
829
  }
819
830
  if (aliasMoved) {
820
- try {
821
- await rename(aliasBackup, repository.path)
822
- aliasMoved = false
823
- } catch (error) {
824
- errors.push(error)
825
- }
831
+ yield* attempt(
832
+ transactionEffect(async () => {
833
+ await rename(aliasBackup, repository.path)
834
+ aliasMoved = false
835
+ }),
836
+ )
826
837
  }
827
- if (errors.length > 0) throw new AggregateError(errors)
828
- }
829
- const migration: TransactionStep = {
838
+ if (errors.length > 0)
839
+ return yield* Effect.fail(new AggregateError(errors))
840
+ })
841
+ const migration: TransactionStep<any> = {
830
842
  label: `materialize linked repository ${repository.alias}`,
831
- preflight: async () => {
832
- const stats = await lstat(repository.path)
843
+ preflight: Effect.gen(function* () {
844
+ const stats = yield* transactionEffect(() =>
845
+ lstat(repository.path),
846
+ )
833
847
  if (
834
848
  !stats.isSymbolicLink() ||
835
- (await realpath(repository.path)) !== source
849
+ (yield* transactionEffect(() => realpath(repository.path))) !==
850
+ source
836
851
  )
837
- throw new Error(
838
- `Repository alias '${repository.alias}' changed during materialization`,
852
+ return yield* Effect.fail(
853
+ new Error(
854
+ `Repository alias '${repository.alias}' changed during materialization`,
855
+ ),
839
856
  )
840
- const current = await Effect.runPromise(
841
- backend
842
- .listWorkspaces(repository.path)
843
- .pipe(
844
- Effect.provideService(FileSystemService, fs),
845
- ) as unknown as Effect.Effect<
846
- readonly RegisteredWorkspace[],
847
- unknown,
848
- never
849
- >,
850
- )
857
+ const current = yield* backend
858
+ .listWorkspaces(repository.path)
859
+ .pipe(
860
+ Effect.provideService(FileSystemService, fs),
861
+ ) as Effect.Effect<readonly RegisteredWorkspace[], unknown, any>
851
862
  const currentState = current
852
863
  .map(({ path, commit, branch }) => ({ path, commit, branch }))
853
864
  .sort((left, right) => left.path.localeCompare(right.path))
854
865
  if (
855
866
  JSON.stringify(currentState) !== JSON.stringify(registeredState)
856
867
  )
857
- throw new Error(
858
- `Git worktree registrations changed during materialization`,
868
+ return yield* Effect.fail(
869
+ new Error(
870
+ `Git worktree registrations changed during materialization`,
871
+ ),
859
872
  )
860
- },
861
- apply: async () => {
862
- try {
863
- if (hasWorktreeMetadata) {
873
+ }),
874
+ apply: Effect.gen(function* () {
875
+ if (hasWorktreeMetadata) {
876
+ yield* transactionEffect(async () => {
864
877
  await rename(sourceWorktrees, metadataBackup)
865
878
  metadataMoved = true
866
- await cp(metadataBackup, join(staging, "worktrees"), {
879
+ })
880
+ yield* transactionEffect(() =>
881
+ cp(metadataBackup, join(staging, "worktrees"), {
867
882
  recursive: true,
868
- })
869
- }
883
+ }),
884
+ )
885
+ }
886
+ yield* transactionEffect(async () => {
870
887
  await rename(repository.path, aliasBackup)
871
888
  aliasMoved = true
889
+ })
890
+ yield* transactionEffect(async () => {
872
891
  await rename(staging, repository.path)
873
892
  cloneInstalled = true
874
- await repair(repository.path)
875
- } catch (cause) {
876
- try {
877
- await rollbackMigration()
878
- } catch (rollbackCause) {
879
- throw new Error(
880
- `Repository materialization failed and rollback requires manual recovery: restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
881
- { cause: new AggregateError([cause, rollbackCause]) },
882
- )
883
- }
884
- throw cause
885
- }
886
- },
893
+ })
894
+ yield* repair(repository.path)
895
+ }).pipe(
896
+ Effect.catchAllCause((cause) =>
897
+ rollbackMigration.pipe(
898
+ Effect.catchAllCause((rollbackCause) =>
899
+ Effect.fail(
900
+ new Error(
901
+ `Repository materialization failed and rollback requires manual recovery: restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
902
+ {
903
+ cause: new AggregateError([
904
+ Cause.squash(cause),
905
+ Cause.squash(rollbackCause),
906
+ ]),
907
+ },
908
+ ),
909
+ ),
910
+ ),
911
+ Effect.zipRight(Effect.failCause(cause)),
912
+ ),
913
+ ),
914
+ ),
887
915
  rollback: rollbackMigration,
888
- finalize: async () => {
916
+ finalize: transactionEffect(async () => {
889
917
  await rm(aliasBackup, { recursive: true, force: true })
890
918
  await rm(metadataBackup, { recursive: true, force: true })
891
- },
919
+ }),
892
920
  manualRecovery: `Restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
893
921
  }
894
922
 
@@ -898,7 +926,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
898
926
  steps: [migration],
899
927
  }).pipe(
900
928
  Effect.mapError(
901
- (cause) => new RepositoryError({ message: cause.message, cause }),
929
+ (cause) =>
930
+ new RepositoryError({
931
+ message:
932
+ cause instanceof Error ? cause.message : String(cause),
933
+ cause,
934
+ }),
902
935
  ),
903
936
  Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
904
937
  )
@@ -1016,7 +1049,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
1016
1049
  ],
1017
1050
  }).pipe(
1018
1051
  Effect.mapError(
1019
- (cause) => new RepositoryError({ message: cause.message, cause }),
1052
+ (cause) =>
1053
+ new RepositoryError({
1054
+ message:
1055
+ cause instanceof Error ? cause.message : String(cause),
1056
+ cause,
1057
+ }),
1020
1058
  ),
1021
1059
  )
1022
1060
  return repository
@@ -1096,7 +1134,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
1096
1134
  ...(state.config.repositories ?? {}),
1097
1135
  [repository.alias]: { remote: portable },
1098
1136
  })
1099
- const steps: TransactionStep[] = []
1137
+ const steps: TransactionStep<any>[] = []
1100
1138
  if (
1101
1139
  repository.kind !== null &&
1102
1140
  repository.kind !== "symlink" &&
@@ -1104,35 +1142,32 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
1104
1142
  ) {
1105
1143
  const previous = repository.remote
1106
1144
  const update = (value: string | null) =>
1107
- Effect.runPromise(
1108
- backend
1109
- .setRemoteUrl(repository.path, "origin", value)
1110
- .pipe(
1111
- Effect.provideService(FileSystemService, fs),
1112
- ) as Effect.Effect<void, unknown, never>,
1113
- )
1145
+ backend
1146
+ .setRemoteUrl(repository.path, "origin", value)
1147
+ .pipe(Effect.provideService(FileSystemService, fs))
1114
1148
  steps.push({
1115
1149
  label: `update origin for repos/${repository.alias}`,
1116
- preflight: async () => {
1117
- const stats = await lstat(repository.path)
1150
+ preflight: Effect.gen(function* () {
1151
+ const stats = yield* Effect.tryPromise({
1152
+ try: () => lstat(repository.path),
1153
+ catch: (cause) => cause,
1154
+ })
1118
1155
  if (stats.isSymbolicLink()) {
1119
1156
  throw new Error(
1120
1157
  `Repository alias '${repository.alias}' changed to a linked checkout; retry the remote update`,
1121
1158
  )
1122
1159
  }
1123
- const currentRemote = await Effect.runPromise(
1124
- backend
1125
- .remoteUrl(repository.path, "origin")
1126
- .pipe(Effect.provideService(FileSystemService, fs)),
1127
- )
1160
+ const currentRemote = yield* backend
1161
+ .remoteUrl(repository.path, "origin")
1162
+ .pipe(Effect.provideService(FileSystemService, fs))
1128
1163
  if (currentRemote !== previous) {
1129
1164
  throw new Error(
1130
1165
  `Origin for repository '${repository.alias}' changed; retry the remote update`,
1131
1166
  )
1132
1167
  }
1133
- },
1134
- apply: () => update(portable),
1135
- rollback: () => update(previous),
1168
+ }),
1169
+ apply: update(portable),
1170
+ rollback: update(previous),
1136
1171
  manualRecovery: `Restore origin for ${repository.path} to ${previous ?? "no remote"}`,
1137
1172
  })
1138
1173
  }
@@ -1,4 +1,4 @@
1
- import { Data, Effect, Layer } from "effect"
1
+ import { Data, Effect } from "effect"
2
2
  import { randomUUID } from "node:crypto"
3
3
  import { lstat, mkdir } from "node:fs/promises"
4
4
  import { dirname } from "node:path"
@@ -19,6 +19,7 @@ import { withWorktreeLocks } from "./WorktreeLock"
19
19
  import {
20
20
  documentWriteStep,
21
21
  runLifecycleTransaction,
22
+ transactionEffect,
22
23
  type TransactionStep,
23
24
  } from "./LifecycleTransaction"
24
25
  import {
@@ -64,21 +65,6 @@ const runGit = async (
64
65
  return stdout.trim()
65
66
  }
66
67
 
67
- const WorktreeLayer = Layer.mergeAll(
68
- FileSystemService.Default,
69
- WorkbaseService.Default,
70
- GitVersionControlService.Default,
71
- VersionControlService.Default,
72
- TaskService.Default,
73
- PhaseService.Default,
74
- WorktreeService.Default,
75
- )
76
-
77
- const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
78
- Effect.runPromise(
79
- effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
80
- )
81
-
82
68
  const restoreSnapshots = async (
83
69
  snapshots: readonly WorktreeRemovalSnapshot[],
84
70
  ) => {
@@ -353,18 +339,26 @@ export class ReviewService extends Effect.Service<ReviewService>()(
353
339
  (checkout) => checkout.exists || checkout.registered,
354
340
  )
355
341
  const snapshots: WorktreeRemovalSnapshot[] = []
356
- const steps: TransactionStep[] = []
342
+ const steps: TransactionStep<any>[] = []
357
343
  if (hadCheckout) {
358
344
  steps.push({
359
345
  label: `remove review checkout for ${taskId}`,
360
- apply: () =>
361
- runWorktreeEffect(
362
- worktrees.remove(taskId, undefined, root, {
363
- snapshots,
364
- lockHeld: true,
365
- }),
366
- ).then(() => undefined),
367
- rollback: () => restoreSnapshots(snapshots),
346
+ apply: worktrees
347
+ .remove(taskId, undefined, root, {
348
+ snapshots,
349
+ lockHeld: true,
350
+ })
351
+ .pipe(
352
+ Effect.asVoid,
353
+ Effect.catchAllCause((cause) =>
354
+ transactionEffect(() =>
355
+ restoreSnapshots(snapshots),
356
+ ).pipe(Effect.zipRight(Effect.failCause(cause))),
357
+ ),
358
+ ),
359
+ rollback: transactionEffect(() =>
360
+ restoreSnapshots(snapshots),
361
+ ),
368
362
  manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
369
363
  })
370
364
  }
@@ -373,7 +367,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
373
367
  )
374
368
  steps.push({
375
369
  label: `advance review pin for ${taskId}`,
376
- apply: () =>
370
+ apply: transactionEffect(() =>
377
371
  runGit(
378
372
  [
379
373
  "git",
@@ -385,8 +379,9 @@ export class ReviewService extends Effect.Service<ReviewService>()(
385
379
  previousReview.commit,
386
380
  ],
387
381
  gitEnvironment,
388
- ).then(() => undefined),
389
- rollback: () =>
382
+ ),
383
+ ),
384
+ rollback: transactionEffect(() =>
390
385
  runGit(
391
386
  [
392
387
  "git",
@@ -398,18 +393,18 @@ export class ReviewService extends Effect.Service<ReviewService>()(
398
393
  latest.commit,
399
394
  ],
400
395
  gitEnvironment,
401
- ).then(() => undefined),
396
+ ),
397
+ ),
402
398
  manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
403
399
  })
404
400
  if (hadCheckout) {
405
401
  steps.push({
406
402
  label: `create refreshed review checkout for ${taskId}`,
407
- apply: () =>
408
- runWorktreeEffect(
409
- worktrees.materialize(taskId, undefined, root, {
410
- lockHeld: true,
411
- }),
412
- ).then(() => undefined),
403
+ apply: worktrees
404
+ .materialize(taskId, undefined, root, {
405
+ lockHeld: true,
406
+ })
407
+ .pipe(Effect.asVoid),
413
408
  manualRecovery: `Run agency work prepare for review task '${taskId}'`,
414
409
  })
415
410
  }
@@ -33,6 +33,7 @@ import {
33
33
  documentWriteStep,
34
34
  pathMustNotExistStep,
35
35
  runLifecycleTransaction,
36
+ transactionEffect,
36
37
  type TransactionStep,
37
38
  } from "./LifecycleTransaction"
38
39
 
@@ -127,7 +128,7 @@ const branchAvailableStep = (
127
128
  destinationId: string,
128
129
  ): TransactionStep => ({
129
130
  label: `verify branch ${repo}:${branch} is available`,
130
- preflight: async () => {
131
+ preflight: transactionEffect(async () => {
131
132
  const taskEntries = await readdir(join(root, "tasks"), {
132
133
  withFileTypes: true,
133
134
  }).catch(() => [])
@@ -170,8 +171,8 @@ const branchAvailableStep = (
170
171
  }
171
172
  }
172
173
  }
173
- },
174
- apply: async () => {},
174
+ }),
175
+ apply: Effect.void,
175
176
  })
176
177
 
177
178
  const reviewPinRef = (taskId: string) =>
@@ -199,7 +200,7 @@ const reviewPinStep = (
199
200
  }
200
201
  return {
201
202
  label: `retain review pin for ${taskId}`,
202
- apply: () =>
203
+ apply: transactionEffect(() =>
203
204
  run([
204
205
  "git",
205
206
  "-C",
@@ -209,7 +210,8 @@ const reviewPinStep = (
209
210
  review.commit,
210
211
  "0".repeat(40),
211
212
  ]),
212
- rollback: () =>
213
+ ),
214
+ rollback: transactionEffect(() =>
213
215
  run([
214
216
  "git",
215
217
  "-C",
@@ -219,6 +221,7 @@ const reviewPinStep = (
219
221
  ref,
220
222
  review.commit,
221
223
  ]),
224
+ ),
222
225
  manualRecovery: `Delete ${ref} from repository '${review.repo}'`,
223
226
  }
224
227
  }
@@ -485,22 +488,21 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
485
488
  postWriteSteps: [
486
489
  {
487
490
  label: "validate resulting workbase",
488
- apply: async () => {
489
- const report = await Effect.runPromise(
490
- workbase
491
- .validate(root)
492
- .pipe(
493
- Effect.provideService(FileSystemService, fs),
494
- Effect.provideService(WorkbaseService, workbase),
495
- ),
496
- )
497
- if (!report.valid) {
498
- throw new Error(
499
- `Handoff would create an invalid workbase: ${report.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
500
- )
501
- }
502
- committedValidation = report
503
- },
491
+ apply: workbase.validate(root).pipe(
492
+ Effect.provideService(FileSystemService, fs),
493
+ Effect.provideService(WorkbaseService, workbase),
494
+ Effect.flatMap((report) => {
495
+ if (!report.valid) {
496
+ return Effect.fail(
497
+ new Error(
498
+ `Handoff would create an invalid workbase: ${report.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
499
+ ),
500
+ )
501
+ }
502
+ committedValidation = report
503
+ return Effect.void
504
+ }),
505
+ ),
504
506
  },
505
507
  ],
506
508
  },