@markjaquith/agency 2.56.1 → 2.58.0
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/README.md +35 -9
- package/package.json +1 -1
- package/src/cli-parser.test.ts +8 -0
- package/src/cli-parser.ts +7 -1
- package/src/commands/archive.test.ts +39 -1
- package/src/commands/archive.ts +25 -2
- package/src/commands/pr.test.ts +40 -1
- package/src/commands/pr.ts +6 -0
- package/src/commands/restore.test.ts +8 -0
- package/src/services/ArchiveBulkService.test.ts +485 -0
- package/src/services/ArchiveService.test.ts +164 -1
- package/src/services/ArchiveService.ts +447 -29
- package/src/services/ContextService.ts +27 -10
- package/src/services/DoctorService.ts +6 -11
- package/src/services/GraphService.ts +60 -102
- package/src/services/PullRequestService.test.ts +45 -1
- package/src/services/PullRequestService.ts +18 -1
- package/src/services/PushService.test.ts +10 -13
- package/src/services/RepositoryService.test.ts +6 -1
- package/src/services/RepositoryService.ts +57 -114
- package/src/services/ReviewService.test.ts +2 -2
- package/src/services/ReviewService.ts +47 -28
- package/src/services/SyncService.test.ts +17 -2
- package/src/services/SyncService.ts +41 -18
- package/src/services/TaskService.ts +18 -2
- package/src/services/VcsMigrationService.test.ts +58 -1
- package/src/services/VcsMigrationService.ts +9 -0
- package/src/services/VersionControlService.test.ts +38 -0
- package/src/services/VersionControlService.ts +211 -2
- package/src/services/WorktreeService.test.ts +4 -0
- package/src/services/WorktreeService.ts +1 -5
- package/src/vcs-status-fast.ts +3 -8
- package/src/workbase/delivery-command.test.ts +11 -2
- package/src/workbase/delivery-command.ts +5 -1
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type TransactionStep,
|
|
45
45
|
} from "./LifecycleTransaction"
|
|
46
46
|
import { withWorktreeLocks } from "./WorktreeLock"
|
|
47
|
+
import { aggregateProgress, isTerminalStatus } from "../readiness"
|
|
47
48
|
|
|
48
49
|
class ArchiveError extends Data.TaggedError("ArchiveError")<{
|
|
49
50
|
readonly message: string
|
|
@@ -104,6 +105,36 @@ interface LifecycleOptions {
|
|
|
104
105
|
readonly dryRun?: boolean
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
type TaskArchiveSkipCode =
|
|
109
|
+
| "non-terminal"
|
|
110
|
+
| "active-claim"
|
|
111
|
+
| "dirty-worktree"
|
|
112
|
+
| "checkout-preflight-failed"
|
|
113
|
+
| "retained-dependent"
|
|
114
|
+
| "destination-exists"
|
|
115
|
+
|
|
116
|
+
interface TaskArchiveDisposition {
|
|
117
|
+
readonly id: string
|
|
118
|
+
readonly disposition: "archived" | "planned" | "skipped"
|
|
119
|
+
readonly reason?: {
|
|
120
|
+
readonly code: TaskArchiveSkipCode
|
|
121
|
+
readonly details: readonly string[]
|
|
122
|
+
}
|
|
123
|
+
readonly path?: string
|
|
124
|
+
readonly affectedPaths: readonly string[]
|
|
125
|
+
readonly removedWorktrees: readonly string[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface BulkTaskArchiveResult {
|
|
129
|
+
readonly operation: "archive"
|
|
130
|
+
readonly kind: "tasks"
|
|
131
|
+
readonly tasks: readonly TaskArchiveDisposition[]
|
|
132
|
+
readonly affectedPaths: readonly string[]
|
|
133
|
+
readonly removedWorktrees: readonly string[]
|
|
134
|
+
readonly dryRun: boolean
|
|
135
|
+
readonly at: string
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
export interface ArchiveFilters {
|
|
108
139
|
readonly kinds?: readonly string[]
|
|
109
140
|
readonly statuses?: readonly string[]
|
|
@@ -128,6 +159,69 @@ interface Write {
|
|
|
128
159
|
readonly content: string
|
|
129
160
|
}
|
|
130
161
|
|
|
162
|
+
interface TaskArchiveContext {
|
|
163
|
+
readonly task: TaskRecord
|
|
164
|
+
readonly phases: readonly PhaseRecord[]
|
|
165
|
+
readonly executionUnits: readonly { taskId: string; phaseId?: string }[]
|
|
166
|
+
readonly terminal: boolean
|
|
167
|
+
readonly terminalDetails: readonly string[]
|
|
168
|
+
readonly activeClaims: readonly string[]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
|
|
172
|
+
Effect.gen(function* () {
|
|
173
|
+
const phases = yield* PhaseService
|
|
174
|
+
if (!("phases" in task.data)) {
|
|
175
|
+
return {
|
|
176
|
+
task,
|
|
177
|
+
phases: [],
|
|
178
|
+
executionUnits: [{ taskId: task.id }],
|
|
179
|
+
terminal: isTerminalStatus(task.data.status),
|
|
180
|
+
terminalDetails: [`status=${task.data.status}`],
|
|
181
|
+
activeClaims:
|
|
182
|
+
task.data.claim?.state === "active" ? [`task:${task.id}`] : [],
|
|
183
|
+
} satisfies TaskArchiveContext
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const phaseRecords: PhaseRecord[] = []
|
|
187
|
+
for (const declaration of task.data.phases) {
|
|
188
|
+
phaseRecords.push(yield* phases.show(task.id, declaration.id, root))
|
|
189
|
+
}
|
|
190
|
+
const statuses = phaseRecords.map((phase) => phase.data.status)
|
|
191
|
+
const effectiveStatus = aggregateProgress(statuses).status
|
|
192
|
+
return {
|
|
193
|
+
task,
|
|
194
|
+
phases: phaseRecords,
|
|
195
|
+
executionUnits: phaseRecords.map((phase) => ({
|
|
196
|
+
taskId: task.id,
|
|
197
|
+
phaseId: phase.id,
|
|
198
|
+
})),
|
|
199
|
+
terminal: phaseRecords.length > 0 && isTerminalStatus(effectiveStatus),
|
|
200
|
+
terminalDetails:
|
|
201
|
+
phaseRecords.length === 0
|
|
202
|
+
? ["multi-phase task has no phases"]
|
|
203
|
+
: phaseRecords.map(
|
|
204
|
+
(phase) => `phase:${phase.id}:status=${phase.data.status}`,
|
|
205
|
+
),
|
|
206
|
+
activeClaims: phaseRecords
|
|
207
|
+
.filter((phase) => phase.data.claim?.state === "active")
|
|
208
|
+
.map((phase) => `phase:${task.id}/${phase.id}`),
|
|
209
|
+
} satisfies TaskArchiveContext
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const archiveEligibilityError = (context: TaskArchiveContext) => {
|
|
213
|
+
if (!context.terminal) {
|
|
214
|
+
return `Task '${context.task.id}' is not terminal (${context.terminalDetails.join(", ")}); only done or dropped tasks can be archived`
|
|
215
|
+
}
|
|
216
|
+
if (context.activeClaims.length > 0) {
|
|
217
|
+
return `Task '${context.task.id}' has active claims (${context.activeClaims.join(", ")}); release or finish them before archiving`
|
|
218
|
+
}
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const executionPhaseId = (unit: { taskId: string; phaseId?: string }) =>
|
|
223
|
+
unit.phaseId
|
|
224
|
+
|
|
131
225
|
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
132
226
|
schema: S,
|
|
133
227
|
input: unknown,
|
|
@@ -735,6 +829,336 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
735
829
|
} satisfies LifecycleResult
|
|
736
830
|
}),
|
|
737
831
|
|
|
832
|
+
archiveTasks: (
|
|
833
|
+
startPath: string = process.cwd(),
|
|
834
|
+
options: LifecycleOptions = {},
|
|
835
|
+
) =>
|
|
836
|
+
Effect.gen(function* () {
|
|
837
|
+
const fs = yield* FileSystemService
|
|
838
|
+
const workbase = yield* WorkbaseService
|
|
839
|
+
const epics = yield* EpicService
|
|
840
|
+
const tasks = yield* TaskService
|
|
841
|
+
const worktrees = yield* WorktreeService
|
|
842
|
+
const root = yield* workbase.discover(startPath)
|
|
843
|
+
const validation = yield* workbase.validate(root)
|
|
844
|
+
if (!validation.valid) {
|
|
845
|
+
return yield* new ArchiveError({
|
|
846
|
+
message: `Cannot plan bulk task archive because the workbase is invalid:\n${validation.issues
|
|
847
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
848
|
+
.join("\n")}`,
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const taskRecords = yield* tasks.list(root)
|
|
853
|
+
const epicRecords = yield* epics.list(root)
|
|
854
|
+
const epicById = new Map(epicRecords.map((epic) => [epic.id, epic]))
|
|
855
|
+
const contexts = new Map<string, TaskArchiveContext>()
|
|
856
|
+
const skipped = new Map<
|
|
857
|
+
string,
|
|
858
|
+
NonNullable<TaskArchiveDisposition["reason"]>
|
|
859
|
+
>()
|
|
860
|
+
const removedByTask = new Map<string, string[]>()
|
|
861
|
+
|
|
862
|
+
for (const task of taskRecords) {
|
|
863
|
+
const context = yield* loadTaskArchiveContext(task, root)
|
|
864
|
+
contexts.set(task.id, context)
|
|
865
|
+
if (!context.terminal) {
|
|
866
|
+
skipped.set(task.id, {
|
|
867
|
+
code: "non-terminal",
|
|
868
|
+
details: context.terminalDetails,
|
|
869
|
+
})
|
|
870
|
+
continue
|
|
871
|
+
}
|
|
872
|
+
if (context.activeClaims.length > 0) {
|
|
873
|
+
skipped.set(task.id, {
|
|
874
|
+
code: "active-claim",
|
|
875
|
+
details: context.activeClaims,
|
|
876
|
+
})
|
|
877
|
+
continue
|
|
878
|
+
}
|
|
879
|
+
const destination = archivedTaskDirectory(root, task.id)
|
|
880
|
+
if (yield* fs.exists(destination)) {
|
|
881
|
+
skipped.set(task.id, {
|
|
882
|
+
code: "destination-exists",
|
|
883
|
+
details: [destination],
|
|
884
|
+
})
|
|
885
|
+
continue
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const removed: string[] = []
|
|
889
|
+
let preflightFailure: unknown
|
|
890
|
+
for (const unit of context.executionUnits) {
|
|
891
|
+
const result = yield* worktrees
|
|
892
|
+
.remove(unit.taskId, executionPhaseId(unit), root, {
|
|
893
|
+
dryRun: true,
|
|
894
|
+
})
|
|
895
|
+
.pipe(Effect.either)
|
|
896
|
+
if (Either.isLeft(result)) {
|
|
897
|
+
preflightFailure = result.left
|
|
898
|
+
break
|
|
899
|
+
}
|
|
900
|
+
removed.push(...result.right)
|
|
901
|
+
}
|
|
902
|
+
if (preflightFailure) {
|
|
903
|
+
if (
|
|
904
|
+
typeof preflightFailure !== "object" ||
|
|
905
|
+
preflightFailure === null ||
|
|
906
|
+
!("_tag" in preflightFailure) ||
|
|
907
|
+
preflightFailure._tag !== "WorktreeError"
|
|
908
|
+
) {
|
|
909
|
+
return yield* Effect.fail(preflightFailure)
|
|
910
|
+
}
|
|
911
|
+
const message =
|
|
912
|
+
"message" in preflightFailure &&
|
|
913
|
+
typeof preflightFailure.message === "string"
|
|
914
|
+
? preflightFailure.message
|
|
915
|
+
: "Managed checkout removal preflight failed"
|
|
916
|
+
const conflicts =
|
|
917
|
+
"conflicts" in preflightFailure &&
|
|
918
|
+
Array.isArray(preflightFailure.conflicts)
|
|
919
|
+
? preflightFailure.conflicts
|
|
920
|
+
: []
|
|
921
|
+
if (
|
|
922
|
+
/^Failed to inspect worktrees/.test(message) ||
|
|
923
|
+
conflicts.some(
|
|
924
|
+
(conflict) =>
|
|
925
|
+
typeof conflict === "object" &&
|
|
926
|
+
conflict !== null &&
|
|
927
|
+
"kind" in conflict &&
|
|
928
|
+
conflict.kind === "inspection-failed",
|
|
929
|
+
)
|
|
930
|
+
) {
|
|
931
|
+
return yield* Effect.fail(preflightFailure)
|
|
932
|
+
}
|
|
933
|
+
const dirty =
|
|
934
|
+
/uncommitted changes|dirty/i.test(message) ||
|
|
935
|
+
conflicts.some(
|
|
936
|
+
(conflict) =>
|
|
937
|
+
typeof conflict === "object" &&
|
|
938
|
+
conflict !== null &&
|
|
939
|
+
"dirty" in conflict &&
|
|
940
|
+
conflict.dirty === true,
|
|
941
|
+
)
|
|
942
|
+
skipped.set(task.id, {
|
|
943
|
+
code: dirty ? "dirty-worktree" : "checkout-preflight-failed",
|
|
944
|
+
details: [message],
|
|
945
|
+
})
|
|
946
|
+
continue
|
|
947
|
+
}
|
|
948
|
+
removedByTask.set(task.id, [...new Set(removed)].sort())
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const cohort = new Set(
|
|
952
|
+
taskRecords
|
|
953
|
+
.filter((task) => !skipped.has(task.id))
|
|
954
|
+
.map((task) => task.id),
|
|
955
|
+
)
|
|
956
|
+
let changed = true
|
|
957
|
+
while (changed) {
|
|
958
|
+
changed = false
|
|
959
|
+
for (const taskId of [...cohort].sort()) {
|
|
960
|
+
const task = contexts.get(taskId)!.task
|
|
961
|
+
if (!task.data.epic) continue
|
|
962
|
+
const parent = epicById.get(task.data.epic)!
|
|
963
|
+
const dependents = parent.data.tasks
|
|
964
|
+
.filter((candidate) => candidate.dependsOn?.includes(taskId))
|
|
965
|
+
.map((candidate) => candidate.id)
|
|
966
|
+
.filter((dependent) => !cohort.has(dependent))
|
|
967
|
+
.sort()
|
|
968
|
+
if (dependents.length === 0) continue
|
|
969
|
+
cohort.delete(taskId)
|
|
970
|
+
skipped.set(taskId, {
|
|
971
|
+
code: "retained-dependent",
|
|
972
|
+
details: dependents,
|
|
973
|
+
})
|
|
974
|
+
changed = true
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const selected = [...cohort].sort().map((id) => contexts.get(id)!)
|
|
979
|
+
const at = new Date().toISOString()
|
|
980
|
+
const writes: (Write & { create?: boolean })[] = []
|
|
981
|
+
const selectedByEpic = new Map<string, Set<string>>()
|
|
982
|
+
for (const context of selected) {
|
|
983
|
+
const epicId = context.task.data.epic
|
|
984
|
+
if (!epicId) continue
|
|
985
|
+
const ids = selectedByEpic.get(epicId) ?? new Set<string>()
|
|
986
|
+
ids.add(context.task.id)
|
|
987
|
+
selectedByEpic.set(epicId, ids)
|
|
988
|
+
}
|
|
989
|
+
for (const [epicId, ids] of [...selectedByEpic].sort(([a], [b]) =>
|
|
990
|
+
a.localeCompare(b),
|
|
991
|
+
)) {
|
|
992
|
+
const parent = epicById.get(epicId)!
|
|
993
|
+
const remaining = parent.data.tasks.filter(
|
|
994
|
+
(task) => !ids.has(task.id),
|
|
995
|
+
)
|
|
996
|
+
const dependencyIssue = validateDependencies(remaining, "Tasks")
|
|
997
|
+
if (dependencyIssue) {
|
|
998
|
+
return yield* new ArchiveError({
|
|
999
|
+
message: `Cannot archive task cohort from epic '${epicId}': ${dependencyIssue}`,
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
writes.push({
|
|
1003
|
+
path: parent.path,
|
|
1004
|
+
content: yield* declarationContent(parent, {
|
|
1005
|
+
...parent.data,
|
|
1006
|
+
tasks: remaining,
|
|
1007
|
+
}),
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
for (const context of selected) {
|
|
1012
|
+
const task = context.task
|
|
1013
|
+
const destination = archivedTaskDirectory(root, task.id)
|
|
1014
|
+
const parent = task.data.epic
|
|
1015
|
+
? epicById.get(task.data.epic)
|
|
1016
|
+
: undefined
|
|
1017
|
+
const declaration = parent?.data.tasks.find(
|
|
1018
|
+
(candidate) => candidate.id === task.id,
|
|
1019
|
+
)
|
|
1020
|
+
const manifestPath = lifecycleManifestPath(dirname(task.path))
|
|
1021
|
+
writes.push({
|
|
1022
|
+
path: manifestPath,
|
|
1023
|
+
create: !(yield* fs.exists(manifestPath)),
|
|
1024
|
+
content: json(
|
|
1025
|
+
manifestFor(
|
|
1026
|
+
yield* readManifest(dirname(task.path)),
|
|
1027
|
+
{
|
|
1028
|
+
kind: "task",
|
|
1029
|
+
id: task.id,
|
|
1030
|
+
...(parent && declaration
|
|
1031
|
+
? {
|
|
1032
|
+
parent: {
|
|
1033
|
+
kind: "epic" as const,
|
|
1034
|
+
id: parent.id,
|
|
1035
|
+
declaration,
|
|
1036
|
+
},
|
|
1037
|
+
}
|
|
1038
|
+
: {}),
|
|
1039
|
+
},
|
|
1040
|
+
event(root, "archive", at, dirname(task.path), destination),
|
|
1041
|
+
),
|
|
1042
|
+
),
|
|
1043
|
+
})
|
|
1044
|
+
}
|
|
1045
|
+
writes.sort((a, b) => a.path.localeCompare(b.path))
|
|
1046
|
+
|
|
1047
|
+
const executionUnits = selected
|
|
1048
|
+
.flatMap((context) => context.executionUnits)
|
|
1049
|
+
.sort((a, b) =>
|
|
1050
|
+
`${a.taskId}/${a.phaseId ?? ""}`.localeCompare(
|
|
1051
|
+
`${b.taskId}/${b.phaseId ?? ""}`,
|
|
1052
|
+
),
|
|
1053
|
+
)
|
|
1054
|
+
if (!options.dryRun && selected.length > 0) {
|
|
1055
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
1056
|
+
const steps: TransactionStep[] = [
|
|
1057
|
+
documentWriteStep(root, writes),
|
|
1058
|
+
{
|
|
1059
|
+
label: "remove worktrees for task archive cohort",
|
|
1060
|
+
apply: async () => {
|
|
1061
|
+
try {
|
|
1062
|
+
for (const unit of executionUnits) {
|
|
1063
|
+
await runWorktreeEffect(
|
|
1064
|
+
worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
1065
|
+
snapshots,
|
|
1066
|
+
lockHeld: true,
|
|
1067
|
+
}),
|
|
1068
|
+
)
|
|
1069
|
+
}
|
|
1070
|
+
} catch (cause) {
|
|
1071
|
+
await restoreWorktreeSnapshots(snapshots)
|
|
1072
|
+
throw cause
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
rollback: () => restoreWorktreeSnapshots(snapshots),
|
|
1076
|
+
manualRecovery:
|
|
1077
|
+
"Run agency work prepare for each archived execution unit",
|
|
1078
|
+
},
|
|
1079
|
+
]
|
|
1080
|
+
for (const context of selected) {
|
|
1081
|
+
steps.push(
|
|
1082
|
+
directoryMoveStep(
|
|
1083
|
+
root,
|
|
1084
|
+
dirname(context.task.path),
|
|
1085
|
+
archivedTaskDirectory(root, context.task.id),
|
|
1086
|
+
),
|
|
1087
|
+
)
|
|
1088
|
+
}
|
|
1089
|
+
const parentPreconditions = [...selectedByEpic.keys()]
|
|
1090
|
+
.sort()
|
|
1091
|
+
.map((id) => epicById.get(id)!)
|
|
1092
|
+
.map((epic) => ({ path: epic.path, revision: epic.revision }))
|
|
1093
|
+
yield* withLifecycleLock(
|
|
1094
|
+
root,
|
|
1095
|
+
withWorktreeLocks(
|
|
1096
|
+
root,
|
|
1097
|
+
executionUnits,
|
|
1098
|
+
runLifecycleTransaction({
|
|
1099
|
+
root,
|
|
1100
|
+
preconditions: [
|
|
1101
|
+
...parentPreconditions,
|
|
1102
|
+
...selected.flatMap((context) => [
|
|
1103
|
+
{
|
|
1104
|
+
path: context.task.path,
|
|
1105
|
+
revision: context.task.revision,
|
|
1106
|
+
},
|
|
1107
|
+
...context.phases.map((phase) => ({
|
|
1108
|
+
path: phase.path,
|
|
1109
|
+
revision: phase.revision,
|
|
1110
|
+
})),
|
|
1111
|
+
]),
|
|
1112
|
+
],
|
|
1113
|
+
steps,
|
|
1114
|
+
}),
|
|
1115
|
+
),
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const dispositions = taskRecords
|
|
1120
|
+
.map((task): TaskArchiveDisposition => {
|
|
1121
|
+
const reason = skipped.get(task.id)
|
|
1122
|
+
if (reason) {
|
|
1123
|
+
return {
|
|
1124
|
+
id: task.id,
|
|
1125
|
+
disposition: "skipped",
|
|
1126
|
+
reason,
|
|
1127
|
+
affectedPaths: [],
|
|
1128
|
+
removedWorktrees: [],
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
const path = archivedTaskDirectory(root, task.id)
|
|
1132
|
+
const parentPath = task.data.epic
|
|
1133
|
+
? epicById.get(task.data.epic)?.path
|
|
1134
|
+
: undefined
|
|
1135
|
+
return {
|
|
1136
|
+
id: task.id,
|
|
1137
|
+
disposition: options.dryRun ? "planned" : "archived",
|
|
1138
|
+
path,
|
|
1139
|
+
affectedPaths: [
|
|
1140
|
+
path,
|
|
1141
|
+
...(parentPath ? [parentPath] : []),
|
|
1142
|
+
].sort(),
|
|
1143
|
+
removedWorktrees: removedByTask.get(task.id) ?? [],
|
|
1144
|
+
}
|
|
1145
|
+
})
|
|
1146
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
1147
|
+
return {
|
|
1148
|
+
operation: "archive",
|
|
1149
|
+
kind: "tasks",
|
|
1150
|
+
tasks: dispositions,
|
|
1151
|
+
affectedPaths: [
|
|
1152
|
+
...new Set(dispositions.flatMap((item) => item.affectedPaths)),
|
|
1153
|
+
].sort(),
|
|
1154
|
+
removedWorktrees: [
|
|
1155
|
+
...new Set(dispositions.flatMap((item) => item.removedWorktrees)),
|
|
1156
|
+
].sort(),
|
|
1157
|
+
dryRun: options.dryRun === true,
|
|
1158
|
+
at,
|
|
1159
|
+
} satisfies BulkTaskArchiveResult
|
|
1160
|
+
}),
|
|
1161
|
+
|
|
738
1162
|
archiveTask: (
|
|
739
1163
|
id: string,
|
|
740
1164
|
startPath: string = process.cwd(),
|
|
@@ -748,9 +1172,11 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
748
1172
|
const worktrees = yield* WorktreeService
|
|
749
1173
|
const root = yield* workbase.discover(startPath)
|
|
750
1174
|
const task = yield* tasks.show(id, root)
|
|
751
|
-
|
|
1175
|
+
const archiveContext = yield* loadTaskArchiveContext(task, root)
|
|
1176
|
+
const eligibilityError = archiveEligibilityError(archiveContext)
|
|
1177
|
+
if (eligibilityError) {
|
|
752
1178
|
return yield* new ArchiveError({
|
|
753
|
-
message:
|
|
1179
|
+
message: eligibilityError,
|
|
754
1180
|
})
|
|
755
1181
|
}
|
|
756
1182
|
const destination = archivedTaskDirectory(root, id)
|
|
@@ -775,32 +1201,19 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
775
1201
|
}
|
|
776
1202
|
}
|
|
777
1203
|
|
|
778
|
-
const executionUnits
|
|
779
|
-
const phaseRecords
|
|
780
|
-
if ("phases" in task.data) {
|
|
781
|
-
for (const phase of task.data.phases) {
|
|
782
|
-
const record = yield* (yield* PhaseService).show(
|
|
783
|
-
id,
|
|
784
|
-
phase.id,
|
|
785
|
-
root,
|
|
786
|
-
)
|
|
787
|
-
if (record.data.claim?.state === "active") {
|
|
788
|
-
return yield* new ArchiveError({
|
|
789
|
-
message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
|
|
790
|
-
})
|
|
791
|
-
}
|
|
792
|
-
phaseRecords.push(record)
|
|
793
|
-
executionUnits.push({ taskId: id, phaseId: phase.id })
|
|
794
|
-
}
|
|
795
|
-
} else {
|
|
796
|
-
executionUnits.push({ taskId: id })
|
|
797
|
-
}
|
|
1204
|
+
const executionUnits = archiveContext.executionUnits
|
|
1205
|
+
const phaseRecords = archiveContext.phases
|
|
798
1206
|
const removedWorktrees: string[] = []
|
|
799
1207
|
for (const unit of executionUnits)
|
|
800
1208
|
removedWorktrees.push(
|
|
801
|
-
...(yield* worktrees.remove(
|
|
802
|
-
|
|
803
|
-
|
|
1209
|
+
...(yield* worktrees.remove(
|
|
1210
|
+
unit.taskId,
|
|
1211
|
+
executionPhaseId(unit),
|
|
1212
|
+
root,
|
|
1213
|
+
{
|
|
1214
|
+
dryRun: true,
|
|
1215
|
+
},
|
|
1216
|
+
)),
|
|
804
1217
|
)
|
|
805
1218
|
const at = new Date().toISOString()
|
|
806
1219
|
const writes: (Write & { create?: boolean })[] = []
|
|
@@ -847,10 +1260,15 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
847
1260
|
try {
|
|
848
1261
|
for (const unit of executionUnits)
|
|
849
1262
|
await runWorktreeEffect(
|
|
850
|
-
worktrees.remove(
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1263
|
+
worktrees.remove(
|
|
1264
|
+
unit.taskId,
|
|
1265
|
+
executionPhaseId(unit),
|
|
1266
|
+
root,
|
|
1267
|
+
{
|
|
1268
|
+
snapshots,
|
|
1269
|
+
lockHeld: true,
|
|
1270
|
+
},
|
|
1271
|
+
),
|
|
854
1272
|
)
|
|
855
1273
|
} catch (cause) {
|
|
856
1274
|
await restoreWorktreeSnapshots(snapshots)
|
|
@@ -973,17 +973,34 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
973
973
|
...(yield* inspectCheckout(repositoryPath, checkoutPath)),
|
|
974
974
|
})
|
|
975
975
|
}
|
|
976
|
-
const
|
|
977
|
-
?
|
|
978
|
-
"ls-remote",
|
|
979
|
-
"origin",
|
|
980
|
-
reviewData.source.kind === "pull-request"
|
|
981
|
-
? reviewData.source.fetchRef
|
|
982
|
-
: reviewData.source.ref
|
|
983
|
-
.replace(/^refs\/remotes\/origin\//, "")
|
|
984
|
-
.replace(/^origin\//, ""),
|
|
985
|
-
])
|
|
976
|
+
const reviewRepository = reviewData
|
|
977
|
+
? repositories.get(reviewData.repo)
|
|
986
978
|
: null
|
|
979
|
+
const reviewSourceCommit =
|
|
980
|
+
reviewData && reviewRepository?.remote
|
|
981
|
+
? yield* fs
|
|
982
|
+
.runCommand(
|
|
983
|
+
[
|
|
984
|
+
"git",
|
|
985
|
+
"ls-remote",
|
|
986
|
+
reviewRepository.remote,
|
|
987
|
+
reviewData.source.kind === "pull-request"
|
|
988
|
+
? reviewData.source.fetchRef
|
|
989
|
+
: reviewData.source.ref
|
|
990
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
991
|
+
.replace(/^origin\//, ""),
|
|
992
|
+
],
|
|
993
|
+
{ captureOutput: true },
|
|
994
|
+
)
|
|
995
|
+
.pipe(
|
|
996
|
+
Effect.map((result) =>
|
|
997
|
+
result.exitCode === 0
|
|
998
|
+
? result.stdout.trim() || null
|
|
999
|
+
: null,
|
|
1000
|
+
),
|
|
1001
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
1002
|
+
)
|
|
1003
|
+
: null
|
|
987
1004
|
|
|
988
1005
|
const checkoutStates = [writable, ...referenceCheckouts].filter(
|
|
989
1006
|
(value): value is NonNullable<typeof value> => value !== null,
|
|
@@ -396,17 +396,12 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
396
396
|
for (const source of reviewSources.filter(
|
|
397
397
|
(item) => item.repo === repository.alias,
|
|
398
398
|
)) {
|
|
399
|
-
const observed =
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
"origin",
|
|
406
|
-
source.ref,
|
|
407
|
-
],
|
|
408
|
-
{ captureOutput: true },
|
|
409
|
-
)
|
|
399
|
+
const observed = repository.remote
|
|
400
|
+
? yield* fs.runCommand(
|
|
401
|
+
["git", "ls-remote", repository.remote, source.ref],
|
|
402
|
+
{ captureOutput: true },
|
|
403
|
+
)
|
|
404
|
+
: { exitCode: -1, stdout: "", stderr: "origin unavailable" }
|
|
410
405
|
const available =
|
|
411
406
|
observed.exitCode === 0 && Boolean(observed.stdout.trim())
|
|
412
407
|
add({
|