@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,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,
|
|
328
|
-
): TransactionStep => ({
|
|
328
|
+
check: Effect.Effect<void, unknown, R>,
|
|
329
|
+
): TransactionStep<R> => ({
|
|
329
330
|
label,
|
|
330
|
-
preflight:
|
|
331
|
-
|
|
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: () =>
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
)
|
|
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) =>
|
|
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
|
-
?
|
|
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 =
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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)
|
|
828
|
-
|
|
829
|
-
|
|
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:
|
|
832
|
-
const stats =
|
|
843
|
+
preflight: Effect.gen(function* () {
|
|
844
|
+
const stats = yield* transactionEffect(() =>
|
|
845
|
+
lstat(repository.path),
|
|
846
|
+
)
|
|
833
847
|
if (
|
|
834
848
|
!stats.isSymbolicLink() ||
|
|
835
|
-
(
|
|
849
|
+
(yield* transactionEffect(() => realpath(repository.path))) !==
|
|
850
|
+
source
|
|
836
851
|
)
|
|
837
|
-
|
|
838
|
-
|
|
852
|
+
return yield* Effect.fail(
|
|
853
|
+
new Error(
|
|
854
|
+
`Repository alias '${repository.alias}' changed during materialization`,
|
|
855
|
+
),
|
|
839
856
|
)
|
|
840
|
-
const current =
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
.
|
|
844
|
-
|
|
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
|
-
|
|
858
|
-
|
|
868
|
+
return yield* Effect.fail(
|
|
869
|
+
new Error(
|
|
870
|
+
`Git worktree registrations changed during materialization`,
|
|
871
|
+
),
|
|
859
872
|
)
|
|
860
|
-
},
|
|
861
|
-
apply:
|
|
862
|
-
|
|
863
|
-
|
|
873
|
+
}),
|
|
874
|
+
apply: Effect.gen(function* () {
|
|
875
|
+
if (hasWorktreeMetadata) {
|
|
876
|
+
yield* transactionEffect(async () => {
|
|
864
877
|
await rename(sourceWorktrees, metadataBackup)
|
|
865
878
|
metadataMoved = true
|
|
866
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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:
|
|
1117
|
-
const stats =
|
|
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 =
|
|
1124
|
-
|
|
1125
|
-
|
|
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:
|
|
1135
|
-
rollback:
|
|
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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
)
|
|
389
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
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
|
},
|
|
@@ -164,6 +164,21 @@ pr: null
|
|
|
164
164
|
expect(exists).not.toContain(join(root, "tasks/example/TASK.md"))
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
+
test("propagates document read failures instead of reporting them as missing", async () => {
|
|
168
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
169
|
+
await mkdir(join(root, "tasks/example/TASK.md"), { recursive: true })
|
|
170
|
+
|
|
171
|
+
await expect(
|
|
172
|
+
runTestEffect(
|
|
173
|
+
WorkbaseService.pipe(
|
|
174
|
+
Effect.flatMap((service) => service.validate(root)),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
).rejects.toThrow(
|
|
178
|
+
`Failed to read file: ${join(root, "tasks/example/TASK.md")}`,
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
167
182
|
test("validates non-PR completion invariants without rejecting legacy done work", async () => {
|
|
168
183
|
await write(
|
|
169
184
|
root,
|
|
@@ -694,7 +694,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
694
694
|
.map((entry) => entry.name)
|
|
695
695
|
.sort(),
|
|
696
696
|
),
|
|
697
|
-
Effect.
|
|
697
|
+
Effect.catchTag("FileNotFoundError", () => Effect.succeed([])),
|
|
698
698
|
)
|
|
699
699
|
|
|
700
700
|
const aliases = new Set(Object.keys(config.repositories ?? {}))
|
|
@@ -710,12 +710,18 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
710
710
|
schema: S,
|
|
711
711
|
) =>
|
|
712
712
|
Effect.gen(function* () {
|
|
713
|
-
const content = yield*
|
|
714
|
-
|
|
713
|
+
const content = yield* fs
|
|
714
|
+
.readFile(path)
|
|
715
|
+
.pipe(
|
|
716
|
+
Effect.catchTag("FileNotFoundError", () =>
|
|
717
|
+
Effect.succeed(null),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
if (content === null) {
|
|
715
721
|
issue(path, "Required document is missing")
|
|
716
722
|
return null
|
|
717
723
|
}
|
|
718
|
-
const documentContent = content
|
|
724
|
+
const documentContent = content
|
|
719
725
|
const parsed = yield* Effect.either(
|
|
720
726
|
parseFrontmatter(documentContent, path),
|
|
721
727
|
)
|