@markjaquith/agency 2.58.2 → 2.60.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 +55 -4
- package/cli-main.ts +5 -0
- package/index.ts +1 -0
- package/package.json +4 -1
- package/schemas/agency-kickoff-v1.schema.json +124 -0
- package/src/cli-parser.test.ts +1 -1
- package/src/cli-parser.ts +23 -6
- package/src/commands/task.test.ts +77 -1
- package/src/commands/task.ts +76 -6
- package/src/commands/work.test.ts +62 -3
- package/src/commands/work.ts +106 -3
- package/src/services/ArchiveService.ts +9 -0
- package/src/services/IntegrationService.test.ts +37 -0
- package/src/services/ReviewService.ts +2 -0
- package/src/services/WorktreeService.test.ts +279 -1
- package/src/services/WorktreeService.ts +418 -30
- package/src/workbase/AGENTS.md +55 -4
- package/src/workbase/kickoff-contract.test.ts +145 -0
- package/src/workbase/kickoff-contract.ts +358 -0
|
@@ -40,6 +40,7 @@ interface WorkspaceOperation {
|
|
|
40
40
|
| "create-branch"
|
|
41
41
|
| "create-worktree"
|
|
42
42
|
| "create-workspace"
|
|
43
|
+
| "restore-workspace"
|
|
43
44
|
| "post-checkout"
|
|
44
45
|
readonly repo: string
|
|
45
46
|
readonly command: readonly string[]
|
|
@@ -154,6 +155,22 @@ export interface WorktreeRemovalSnapshot {
|
|
|
154
155
|
readonly workspaceName?: string
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
interface JjResumeCheckout {
|
|
159
|
+
readonly repo: string
|
|
160
|
+
readonly workspacePath: string
|
|
161
|
+
readonly workspaceName: string
|
|
162
|
+
readonly commitId: string
|
|
163
|
+
readonly changeId: string
|
|
164
|
+
readonly bookmark: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface JjResumeState {
|
|
168
|
+
readonly version: 1
|
|
169
|
+
readonly taskId: string
|
|
170
|
+
readonly phaseId: string | null
|
|
171
|
+
readonly checkouts: readonly JjResumeCheckout[]
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
const parseWorktreeList = (output: string): readonly GitWorktree[] => {
|
|
158
175
|
const worktrees: GitWorktree[] = []
|
|
159
176
|
let current: { path: string; head?: string; branch?: string } | undefined
|
|
@@ -255,12 +272,14 @@ interface MaterializeOptions extends BaseCommandOptions {
|
|
|
255
272
|
readonly force?: boolean
|
|
256
273
|
readonly lockHeld?: boolean
|
|
257
274
|
readonly allowReferenceDrift?: boolean
|
|
275
|
+
readonly validationAlreadyPerformed?: boolean
|
|
258
276
|
}
|
|
259
277
|
|
|
260
278
|
interface RemoveOptions extends BaseCommandOptions {
|
|
261
279
|
readonly snapshots?: WorktreeRemovalSnapshot[]
|
|
262
280
|
readonly lockHeld?: boolean
|
|
263
281
|
readonly allowReferenceDrift?: boolean
|
|
282
|
+
readonly persistResume?: boolean
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
interface LifecycleOptions extends BaseCommandOptions {
|
|
@@ -772,6 +791,177 @@ const jjWorkspaceName = (
|
|
|
772
791
|
repo: string,
|
|
773
792
|
) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
|
|
774
793
|
|
|
794
|
+
const jjResumePath = (taskPath: string, phasePath: string | null) =>
|
|
795
|
+
join(dirname(phasePath ?? taskPath), ".agency-jj-resume.json")
|
|
796
|
+
|
|
797
|
+
const jjResumeBookmark = (
|
|
798
|
+
taskId: string,
|
|
799
|
+
phaseId: string | undefined,
|
|
800
|
+
repo: string,
|
|
801
|
+
) =>
|
|
802
|
+
`agency-resume/${Buffer.from(`${taskId}:${phaseId ?? "task"}:${repo}:${crypto.randomUUID()}`).toString("hex")}`
|
|
803
|
+
|
|
804
|
+
const parseJjResumeState = (content: string, path: string): JjResumeState => {
|
|
805
|
+
let value: unknown
|
|
806
|
+
try {
|
|
807
|
+
value = JSON.parse(content)
|
|
808
|
+
} catch {
|
|
809
|
+
throw new WorktreeError({
|
|
810
|
+
message: `Invalid jj resume metadata at ${path}`,
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
if (
|
|
814
|
+
typeof value !== "object" ||
|
|
815
|
+
value === null ||
|
|
816
|
+
!("version" in value) ||
|
|
817
|
+
value.version !== 1 ||
|
|
818
|
+
!("taskId" in value) ||
|
|
819
|
+
typeof value.taskId !== "string" ||
|
|
820
|
+
!("phaseId" in value) ||
|
|
821
|
+
(value.phaseId !== null && typeof value.phaseId !== "string") ||
|
|
822
|
+
!("checkouts" in value) ||
|
|
823
|
+
!Array.isArray(value.checkouts) ||
|
|
824
|
+
value.checkouts.some(
|
|
825
|
+
(checkout) =>
|
|
826
|
+
typeof checkout !== "object" ||
|
|
827
|
+
checkout === null ||
|
|
828
|
+
![
|
|
829
|
+
"repo",
|
|
830
|
+
"workspacePath",
|
|
831
|
+
"workspaceName",
|
|
832
|
+
"commitId",
|
|
833
|
+
"changeId",
|
|
834
|
+
"bookmark",
|
|
835
|
+
].every((key) => key in checkout && typeof checkout[key] === "string"),
|
|
836
|
+
)
|
|
837
|
+
) {
|
|
838
|
+
throw new WorktreeError({
|
|
839
|
+
message: `Invalid jj resume metadata at ${path}`,
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
return value as JjResumeState
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const readJjResumeState = (path: string) =>
|
|
846
|
+
Effect.gen(function* () {
|
|
847
|
+
const fs = yield* FileSystemService
|
|
848
|
+
if (!(yield* fs.exists(path))) return null
|
|
849
|
+
return parseJjResumeState(yield* fs.readFile(path), path)
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
const writeJjResumeState = (path: string, state: JjResumeState) =>
|
|
853
|
+
Effect.gen(function* () {
|
|
854
|
+
const fs = yield* FileSystemService
|
|
855
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
856
|
+
yield* Effect.gen(function* () {
|
|
857
|
+
yield* fs.writeJSON(temporary, state)
|
|
858
|
+
yield* fs.moveDirectory(temporary, path)
|
|
859
|
+
}).pipe(
|
|
860
|
+
Effect.catchAll((cause) =>
|
|
861
|
+
Effect.gen(function* () {
|
|
862
|
+
if (yield* fs.exists(temporary)) yield* fs.deleteFile(temporary)
|
|
863
|
+
return yield* Effect.fail(cause)
|
|
864
|
+
}),
|
|
865
|
+
),
|
|
866
|
+
)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
const jjIdentity = (repositoryPath: string, revision: string) =>
|
|
870
|
+
Effect.gen(function* () {
|
|
871
|
+
const fs = yield* FileSystemService
|
|
872
|
+
const result = yield* fs.runCommand(
|
|
873
|
+
[
|
|
874
|
+
"jj",
|
|
875
|
+
"-R",
|
|
876
|
+
repositoryPath,
|
|
877
|
+
"--no-pager",
|
|
878
|
+
"log",
|
|
879
|
+
"--ignore-working-copy",
|
|
880
|
+
"--no-graph",
|
|
881
|
+
"-r",
|
|
882
|
+
revision,
|
|
883
|
+
"-T",
|
|
884
|
+
'commit_id ++ "\\t" ++ change_id ++ "\\n"',
|
|
885
|
+
],
|
|
886
|
+
{ captureOutput: true },
|
|
887
|
+
)
|
|
888
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean)
|
|
889
|
+
if (result.exitCode !== 0 || lines.length !== 1) return null
|
|
890
|
+
const [commitId, changeId] = lines[0]!.split("\t")
|
|
891
|
+
return commitId && changeId ? { commitId, changeId } : null
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
const jjSetBookmark = (
|
|
895
|
+
repositoryPath: string,
|
|
896
|
+
bookmark: string,
|
|
897
|
+
commitId: string,
|
|
898
|
+
) =>
|
|
899
|
+
Effect.gen(function* () {
|
|
900
|
+
const fs = yield* FileSystemService
|
|
901
|
+
const result = yield* fs.runCommand(
|
|
902
|
+
[
|
|
903
|
+
"jj",
|
|
904
|
+
"-R",
|
|
905
|
+
repositoryPath,
|
|
906
|
+
"bookmark",
|
|
907
|
+
"create",
|
|
908
|
+
bookmark,
|
|
909
|
+
"-r",
|
|
910
|
+
commitId,
|
|
911
|
+
],
|
|
912
|
+
{ captureOutput: true },
|
|
913
|
+
)
|
|
914
|
+
if (result.exitCode !== 0)
|
|
915
|
+
return yield* new WorktreeError({
|
|
916
|
+
message: `Failed to create internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
|
|
917
|
+
})
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
const jjDeleteBookmark = (repositoryPath: string, bookmark: string) =>
|
|
921
|
+
Effect.gen(function* () {
|
|
922
|
+
const fs = yield* FileSystemService
|
|
923
|
+
const result = yield* fs.runCommand(
|
|
924
|
+
["jj", "-R", repositoryPath, "bookmark", "delete", bookmark],
|
|
925
|
+
{ captureOutput: true },
|
|
926
|
+
)
|
|
927
|
+
if (result.exitCode !== 0)
|
|
928
|
+
return yield* new WorktreeError({
|
|
929
|
+
message: `Failed to delete internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
|
|
930
|
+
})
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
const jjEditWorkspace = (workspacePath: string, commitId: string) =>
|
|
934
|
+
Effect.gen(function* () {
|
|
935
|
+
const fs = yield* FileSystemService
|
|
936
|
+
const result = yield* fs.runCommand(
|
|
937
|
+
["jj", "-R", workspacePath, "edit", commitId],
|
|
938
|
+
{ captureOutput: true },
|
|
939
|
+
)
|
|
940
|
+
if (result.exitCode !== 0)
|
|
941
|
+
return yield* new WorktreeError({
|
|
942
|
+
message: `Failed to restore jj workspace ${workspacePath} at ${commitId}: ${result.stderr.trim()}`,
|
|
943
|
+
})
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
const restoreJjWorkspace = (
|
|
947
|
+
backend: VersionControlBackend,
|
|
948
|
+
checkout: {
|
|
949
|
+
repositoryPath: string
|
|
950
|
+
workspacePath: string
|
|
951
|
+
workspaceName: string
|
|
952
|
+
commitId: string
|
|
953
|
+
},
|
|
954
|
+
) =>
|
|
955
|
+
Effect.gen(function* () {
|
|
956
|
+
yield* backend.createWorkspace({
|
|
957
|
+
repositoryPath: checkout.repositoryPath,
|
|
958
|
+
workspacePath: checkout.workspacePath,
|
|
959
|
+
workspaceName: checkout.workspaceName,
|
|
960
|
+
revision: checkout.commitId,
|
|
961
|
+
})
|
|
962
|
+
yield* jjEditWorkspace(checkout.workspacePath, checkout.commitId)
|
|
963
|
+
})
|
|
964
|
+
|
|
775
965
|
// A process can stop after `jj workspace forget` but before checkout deletion.
|
|
776
966
|
// Only recover that residual when its repository and complete tree still match.
|
|
777
967
|
const inspectJjResidual = (
|
|
@@ -969,6 +1159,8 @@ const materializeJj = (options: {
|
|
|
969
1159
|
workspacePath: string
|
|
970
1160
|
workspaceName: string
|
|
971
1161
|
}[] = []
|
|
1162
|
+
const resumePath = jjResumePath(options.taskPath, options.phasePath)
|
|
1163
|
+
const resume = yield* readJjResumeState(resumePath)
|
|
972
1164
|
const base = "base" in options.execution ? options.execution.base : null
|
|
973
1165
|
const { verboseLog } = createLoggers(options.commandOptions)
|
|
974
1166
|
const forwardCommandOutput =
|
|
@@ -978,6 +1170,40 @@ const materializeJj = (options: {
|
|
|
978
1170
|
|
|
979
1171
|
if (!options.commandOptions.dryRun)
|
|
980
1172
|
yield* fs.createDirectory(options.codePath)
|
|
1173
|
+
if (resume) {
|
|
1174
|
+
const requestedRepos = [
|
|
1175
|
+
...options.requestedCheckouts.map(({ repo }) => repo),
|
|
1176
|
+
].sort()
|
|
1177
|
+
const resumeRepos = [...resume.checkouts.map(({ repo }) => repo)].sort()
|
|
1178
|
+
if (
|
|
1179
|
+
resume.taskId !== options.taskId ||
|
|
1180
|
+
resume.phaseId !== (options.phaseId ?? null) ||
|
|
1181
|
+
requestedRepos.join("\0") !== resumeRepos.join("\0")
|
|
1182
|
+
) {
|
|
1183
|
+
return yield* new WorktreeError({
|
|
1184
|
+
message: `Jj resume metadata at ${resumePath} does not match this execution unit; restore or remove it manually before retrying`,
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
for (const checkout of resume.checkouts) {
|
|
1188
|
+
const repositoryPath = join(options.root, "repos", checkout.repo)
|
|
1189
|
+
const bookmark = yield* jjIdentity(repositoryPath, checkout.bookmark)
|
|
1190
|
+
const commit = yield* jjIdentity(repositoryPath, checkout.commitId)
|
|
1191
|
+
if (
|
|
1192
|
+
!bookmark ||
|
|
1193
|
+
!commit ||
|
|
1194
|
+
bookmark.commitId !== checkout.commitId ||
|
|
1195
|
+
bookmark.changeId !== checkout.changeId ||
|
|
1196
|
+
commit.changeId !== checkout.changeId ||
|
|
1197
|
+
checkout.workspacePath !== join(options.codePath, checkout.repo) ||
|
|
1198
|
+
checkout.workspaceName !==
|
|
1199
|
+
jjWorkspaceName(options.taskId, options.phaseId, checkout.repo)
|
|
1200
|
+
) {
|
|
1201
|
+
return yield* new WorktreeError({
|
|
1202
|
+
message: `Cannot resume jj workspace for '${checkout.repo}'; recorded commit, change ID, path, or internal bookmark no longer agrees`,
|
|
1203
|
+
})
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
981
1207
|
|
|
982
1208
|
return yield* Effect.gen(function* () {
|
|
983
1209
|
for (const checkout of options.requestedCheckouts) {
|
|
@@ -988,19 +1214,38 @@ const materializeJj = (options: {
|
|
|
988
1214
|
options.phaseId,
|
|
989
1215
|
checkout.repo,
|
|
990
1216
|
)
|
|
1217
|
+
const resumeCheckout = resume?.checkouts.find(
|
|
1218
|
+
(entry) => entry.repo === checkout.repo,
|
|
1219
|
+
)
|
|
991
1220
|
if (!(yield* fs.exists(repositoryPath))) {
|
|
992
1221
|
return yield* new WorktreeError({
|
|
993
1222
|
message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
|
|
994
1223
|
})
|
|
995
1224
|
}
|
|
996
1225
|
|
|
997
|
-
|
|
1226
|
+
let exists = yield* fs.isDirectory(workspacePath)
|
|
998
1227
|
const canonicalPath = exists
|
|
999
1228
|
? yield* fs.realPath(workspacePath)
|
|
1000
1229
|
: resolve(workspacePath)
|
|
1001
1230
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1002
1231
|
(workspace) => workspace.path === canonicalPath,
|
|
1003
1232
|
)
|
|
1233
|
+
if (exists && !registered && resumeCheckout) {
|
|
1234
|
+
const residual = yield* inspectJjResidual(
|
|
1235
|
+
options.root,
|
|
1236
|
+
repositoryPath,
|
|
1237
|
+
workspacePath,
|
|
1238
|
+
resumeCheckout.commitId,
|
|
1239
|
+
)
|
|
1240
|
+
if (residual !== "clean") {
|
|
1241
|
+
return yield* new WorktreeError({
|
|
1242
|
+
message: `Cannot resume unregistered jj checkout ${workspacePath}; ${residual === "modified" ? "its contents differ from the recorded commit" : "its repository identity cannot be verified"}`,
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
if (!options.commandOptions.dryRun)
|
|
1246
|
+
yield* fs.deleteDirectory(workspacePath)
|
|
1247
|
+
exists = false
|
|
1248
|
+
}
|
|
1004
1249
|
if (exists && !registered) {
|
|
1005
1250
|
return yield* new WorktreeError({
|
|
1006
1251
|
message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
|
|
@@ -1012,8 +1257,15 @@ const materializeJj = (options: {
|
|
|
1012
1257
|
})
|
|
1013
1258
|
}
|
|
1014
1259
|
if (exists && registered) {
|
|
1015
|
-
const actualCommit =
|
|
1016
|
-
|
|
1260
|
+
const actualCommit = resumeCheckout
|
|
1261
|
+
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
1262
|
+
: yield* backend.workspaceHead(workspacePath)
|
|
1263
|
+
if (resumeCheckout && actualCommit !== resumeCheckout.commitId) {
|
|
1264
|
+
return yield* new WorktreeError({
|
|
1265
|
+
message: `Existing jj workspace ${workspacePath} is at ${actualCommit ?? "an unknown target"}, not recorded resume commit ${resumeCheckout.commitId}`,
|
|
1266
|
+
})
|
|
1267
|
+
}
|
|
1268
|
+
if ("ref" in checkout && !resumeCheckout) {
|
|
1017
1269
|
const expectedCommit = yield* backend.resolveRevision(
|
|
1018
1270
|
repositoryPath,
|
|
1019
1271
|
checkout.ref,
|
|
@@ -1045,10 +1297,9 @@ const materializeJj = (options: {
|
|
|
1045
1297
|
|
|
1046
1298
|
const requestedRevision =
|
|
1047
1299
|
"branch" in checkout ? checkout.branch : checkout.ref
|
|
1048
|
-
let revision =
|
|
1049
|
-
|
|
1050
|
-
requestedRevision
|
|
1051
|
-
)
|
|
1300
|
+
let revision =
|
|
1301
|
+
resumeCheckout?.commitId ??
|
|
1302
|
+
(yield* backend.resolveRevision(repositoryPath, requestedRevision))
|
|
1052
1303
|
if (!revision && "branch" in checkout && base) {
|
|
1053
1304
|
revision = yield* backend.resolveRevision(repositoryPath, base)
|
|
1054
1305
|
}
|
|
@@ -1080,20 +1331,45 @@ const materializeJj = (options: {
|
|
|
1080
1331
|
command,
|
|
1081
1332
|
status: options.commandOptions.dryRun ? "planned" : "completed",
|
|
1082
1333
|
})
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1334
|
+
if (resumeCheckout) {
|
|
1335
|
+
operations.push({
|
|
1336
|
+
action: "restore-workspace",
|
|
1337
|
+
repo: checkout.repo,
|
|
1338
|
+
command: [
|
|
1339
|
+
"jj",
|
|
1340
|
+
"-R",
|
|
1341
|
+
workspacePath,
|
|
1342
|
+
"edit",
|
|
1343
|
+
resumeCheckout.commitId,
|
|
1344
|
+
],
|
|
1345
|
+
status: options.commandOptions.dryRun ? "planned" : "completed",
|
|
1090
1346
|
})
|
|
1347
|
+
}
|
|
1348
|
+
if (!options.commandOptions.dryRun) {
|
|
1349
|
+
if (resumeCheckout) {
|
|
1350
|
+
yield* restoreJjWorkspace(backend, {
|
|
1351
|
+
repositoryPath,
|
|
1352
|
+
workspacePath,
|
|
1353
|
+
workspaceName,
|
|
1354
|
+
commitId: resumeCheckout.commitId,
|
|
1355
|
+
})
|
|
1356
|
+
} else {
|
|
1357
|
+
yield* backend.createWorkspace({
|
|
1358
|
+
repositoryPath,
|
|
1359
|
+
workspacePath,
|
|
1360
|
+
workspaceName,
|
|
1361
|
+
revision,
|
|
1362
|
+
...("branch" in checkout ? { branch: checkout.branch } : {}),
|
|
1363
|
+
})
|
|
1364
|
+
}
|
|
1091
1365
|
created.push({ repositoryPath, workspacePath, workspaceName })
|
|
1092
1366
|
const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
|
|
1093
1367
|
const registeredAfterCreate = (yield* backend.listWorkspaces(
|
|
1094
1368
|
repositoryPath,
|
|
1095
1369
|
)).find((workspace) => workspace.path === canonicalWorkspacePath)
|
|
1096
|
-
const head =
|
|
1370
|
+
const head = resumeCheckout
|
|
1371
|
+
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
1372
|
+
: yield* backend.workspaceHead(workspacePath)
|
|
1097
1373
|
if (!registeredAfterCreate || head !== revision) {
|
|
1098
1374
|
return yield* new WorktreeError({
|
|
1099
1375
|
message: `Created jj workspace for '${checkout.repo}' failed validation`,
|
|
@@ -1130,6 +1406,16 @@ const materializeJj = (options: {
|
|
|
1130
1406
|
})
|
|
1131
1407
|
}
|
|
1132
1408
|
|
|
1409
|
+
if (resume && !options.commandOptions.dryRun) {
|
|
1410
|
+
yield* fs.deleteFile(resumePath)
|
|
1411
|
+
for (const checkout of resume.checkouts) {
|
|
1412
|
+
yield* jjDeleteBookmark(
|
|
1413
|
+
join(options.root, "repos", checkout.repo),
|
|
1414
|
+
checkout.bookmark,
|
|
1415
|
+
).pipe(Effect.ignore)
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1133
1419
|
return {
|
|
1134
1420
|
root: options.root,
|
|
1135
1421
|
taskPath: options.taskPath,
|
|
@@ -1209,6 +1495,16 @@ const removeJj = (
|
|
|
1209
1495
|
"phases" in task.data && phaseId
|
|
1210
1496
|
? (yield* phases.show(taskId, phaseId, root)).data
|
|
1211
1497
|
: task.data
|
|
1498
|
+
const phasePath =
|
|
1499
|
+
"phases" in task.data && phaseId
|
|
1500
|
+
? (yield* phases.show(taskId, phaseId, root)).path
|
|
1501
|
+
: null
|
|
1502
|
+
const resumePath = jjResumePath(task.path, phasePath)
|
|
1503
|
+
if (yield* fs.exists(resumePath)) {
|
|
1504
|
+
return yield* new WorktreeError({
|
|
1505
|
+
message: `Jj resume metadata already exists at ${resumePath}; run 'agency work prepare' to complete the previous resume before removing the workspace again`,
|
|
1506
|
+
})
|
|
1507
|
+
}
|
|
1212
1508
|
const recoverable = new Map<string, string>()
|
|
1213
1509
|
const modifiedResiduals = new Map<string, string>()
|
|
1214
1510
|
for (const checkout of inspection.checkouts) {
|
|
@@ -1242,7 +1538,6 @@ const removeJj = (
|
|
|
1242
1538
|
checkout.conflicts
|
|
1243
1539
|
.filter(
|
|
1244
1540
|
(conflict) =>
|
|
1245
|
-
conflict.kind !== "stale-registration" &&
|
|
1246
1541
|
!(
|
|
1247
1542
|
options.allowReferenceDrift && conflict.kind === "reference-drift"
|
|
1248
1543
|
) &&
|
|
@@ -1269,48 +1564,79 @@ const removeJj = (
|
|
|
1269
1564
|
}
|
|
1270
1565
|
|
|
1271
1566
|
const plans: {
|
|
1567
|
+
repo: string
|
|
1272
1568
|
repositoryPath: string
|
|
1273
1569
|
workspacePath: string
|
|
1274
1570
|
workspaceName: string
|
|
1275
1571
|
head: string
|
|
1276
1572
|
branch: string | null
|
|
1277
1573
|
registered: boolean
|
|
1574
|
+
commitId: string
|
|
1575
|
+
changeId: string
|
|
1576
|
+
bookmark: string
|
|
1278
1577
|
}[] = []
|
|
1279
1578
|
for (const checkout of inspection.checkouts) {
|
|
1280
|
-
if (checkout.dirty) {
|
|
1579
|
+
if (checkout.dirty === true && options.persistResume === false) {
|
|
1281
1580
|
return yield* new WorktreeError({
|
|
1282
1581
|
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1283
1582
|
})
|
|
1284
1583
|
}
|
|
1584
|
+
if (
|
|
1585
|
+
checkout.exists &&
|
|
1586
|
+
checkout.dirty === null &&
|
|
1587
|
+
!recoverable.has(checkout.path)
|
|
1588
|
+
) {
|
|
1589
|
+
return yield* new WorktreeError({
|
|
1590
|
+
message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
|
|
1591
|
+
})
|
|
1592
|
+
}
|
|
1285
1593
|
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1286
1594
|
if (!checkout.registeredPath) {
|
|
1287
1595
|
const recoveryRevision = recoverable.get(checkout.path)
|
|
1288
1596
|
if (!recoveryRevision) continue
|
|
1597
|
+
const identity = yield* jjIdentity(repositoryPath, recoveryRevision)
|
|
1598
|
+
if (!identity) {
|
|
1599
|
+
return yield* new WorktreeError({
|
|
1600
|
+
message: `Cannot prove the exact jj target for unregistered checkout ${checkout.path}`,
|
|
1601
|
+
})
|
|
1602
|
+
}
|
|
1289
1603
|
plans.push({
|
|
1604
|
+
repo: checkout.repo,
|
|
1290
1605
|
repositoryPath,
|
|
1291
1606
|
workspacePath: checkout.path,
|
|
1292
1607
|
workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
|
|
1293
1608
|
head: recoveryRevision,
|
|
1294
1609
|
branch: checkout.actualBranch,
|
|
1295
1610
|
registered: false,
|
|
1611
|
+
...identity,
|
|
1612
|
+
bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
|
|
1296
1613
|
})
|
|
1297
1614
|
continue
|
|
1298
1615
|
}
|
|
1299
1616
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1300
1617
|
(workspace) => workspace.path === checkout.registeredPath,
|
|
1301
1618
|
)
|
|
1302
|
-
if (!registered?.name || !checkout.actualCommit) {
|
|
1619
|
+
if (!registered?.name || !checkout.actualCommit || !registered.commit) {
|
|
1303
1620
|
return yield* new WorktreeError({
|
|
1304
1621
|
message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
|
|
1305
1622
|
})
|
|
1306
1623
|
}
|
|
1624
|
+
const identity = yield* jjIdentity(repositoryPath, registered.commit)
|
|
1625
|
+
if (!identity || identity.commitId !== registered.commit) {
|
|
1626
|
+
return yield* new WorktreeError({
|
|
1627
|
+
message: `Cannot prove the exact jj target for workspace at ${checkout.registeredPath}`,
|
|
1628
|
+
})
|
|
1629
|
+
}
|
|
1307
1630
|
plans.push({
|
|
1631
|
+
repo: checkout.repo,
|
|
1308
1632
|
repositoryPath,
|
|
1309
1633
|
workspacePath: checkout.path,
|
|
1310
1634
|
workspaceName: registered.name,
|
|
1311
|
-
head:
|
|
1635
|
+
head: identity.commitId,
|
|
1312
1636
|
branch: checkout.actualBranch,
|
|
1313
1637
|
registered: true,
|
|
1638
|
+
...identity,
|
|
1639
|
+
bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
|
|
1314
1640
|
})
|
|
1315
1641
|
}
|
|
1316
1642
|
|
|
@@ -1327,7 +1653,33 @@ const removeJj = (
|
|
|
1327
1653
|
if (options.dryRun) return plans.map((plan) => plan.workspacePath)
|
|
1328
1654
|
|
|
1329
1655
|
const completed: typeof plans = []
|
|
1656
|
+
const persistent = options.persistResume !== false && plans.length > 0
|
|
1657
|
+
const resume: JjResumeState = {
|
|
1658
|
+
version: 1,
|
|
1659
|
+
taskId,
|
|
1660
|
+
phaseId: phaseId ?? null,
|
|
1661
|
+
checkouts: plans.map((plan) => ({
|
|
1662
|
+
repo: plan.repo,
|
|
1663
|
+
workspacePath: plan.workspacePath,
|
|
1664
|
+
workspaceName: plan.workspaceName,
|
|
1665
|
+
commitId: plan.commitId,
|
|
1666
|
+
changeId: plan.changeId,
|
|
1667
|
+
bookmark: plan.bookmark,
|
|
1668
|
+
})),
|
|
1669
|
+
}
|
|
1670
|
+
const bookmarks: typeof plans = []
|
|
1330
1671
|
return yield* Effect.gen(function* () {
|
|
1672
|
+
if (persistent) {
|
|
1673
|
+
for (const plan of plans) {
|
|
1674
|
+
yield* jjSetBookmark(
|
|
1675
|
+
plan.repositoryPath,
|
|
1676
|
+
plan.bookmark,
|
|
1677
|
+
plan.commitId,
|
|
1678
|
+
)
|
|
1679
|
+
bookmarks.push(plan)
|
|
1680
|
+
}
|
|
1681
|
+
yield* writeJjResumeState(resumePath, resume)
|
|
1682
|
+
}
|
|
1331
1683
|
for (const plan of plans) {
|
|
1332
1684
|
if (plan.registered) {
|
|
1333
1685
|
yield* backend.removeWorkspace({
|
|
@@ -1347,19 +1699,45 @@ const removeJj = (
|
|
|
1347
1699
|
Effect.gen(function* () {
|
|
1348
1700
|
const rolledBack: string[] = []
|
|
1349
1701
|
const manualRecovery: string[] = []
|
|
1350
|
-
|
|
1702
|
+
const rollback = [...completed]
|
|
1703
|
+
for (const plan of plans) {
|
|
1704
|
+
if (completed.includes(plan)) continue
|
|
1705
|
+
const registered = (yield* backend.listWorkspaces(
|
|
1706
|
+
plan.repositoryPath,
|
|
1707
|
+
)).some((workspace) => workspace.name === plan.workspaceName)
|
|
1708
|
+
if (!registered) rollback.push(plan)
|
|
1709
|
+
}
|
|
1710
|
+
for (const plan of rollback.reverse()) {
|
|
1711
|
+
if (yield* fs.exists(plan.workspacePath)) {
|
|
1712
|
+
const removed = yield* fs
|
|
1713
|
+
.deleteDirectory(plan.workspacePath)
|
|
1714
|
+
.pipe(
|
|
1715
|
+
Effect.as(true),
|
|
1716
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
1717
|
+
)
|
|
1718
|
+
if (!removed) {
|
|
1719
|
+
manualRecovery.push(`Restore ${plan.workspacePath}`)
|
|
1720
|
+
continue
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1351
1723
|
const restored = yield* Effect.either(
|
|
1352
|
-
backend
|
|
1724
|
+
restoreJjWorkspace(backend, {
|
|
1353
1725
|
repositoryPath: plan.repositoryPath,
|
|
1354
1726
|
workspacePath: plan.workspacePath,
|
|
1355
1727
|
workspaceName: plan.workspaceName,
|
|
1356
|
-
|
|
1357
|
-
...(plan.branch ? { branch: plan.branch } : {}),
|
|
1728
|
+
commitId: plan.commitId,
|
|
1358
1729
|
}),
|
|
1359
1730
|
)
|
|
1360
1731
|
if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
|
|
1361
1732
|
else manualRecovery.push(`Restore ${plan.workspacePath}`)
|
|
1362
1733
|
}
|
|
1734
|
+
if (manualRecovery.length === 0 && persistent) {
|
|
1735
|
+
if (yield* fs.exists(resumePath)) yield* fs.deleteFile(resumePath)
|
|
1736
|
+
for (const plan of bookmarks)
|
|
1737
|
+
yield* jjDeleteBookmark(plan.repositoryPath, plan.bookmark).pipe(
|
|
1738
|
+
Effect.ignore,
|
|
1739
|
+
)
|
|
1740
|
+
}
|
|
1363
1741
|
return yield* new WorktreeError({
|
|
1364
1742
|
message: manualRecovery.length
|
|
1365
1743
|
? "Workspace removal failed and requires manual recovery"
|
|
@@ -1473,12 +1851,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1473
1851
|
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
1474
1852
|
const backend = yield* versionControl.forWorkbase(root)
|
|
1475
1853
|
const materialization = Effect.gen(function* () {
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1854
|
+
if (!options.validationAlreadyPerformed) {
|
|
1855
|
+
const report = yield* workbase.validate(root)
|
|
1856
|
+
const validationIssue = report.issues[0]
|
|
1857
|
+
if (validationIssue && !options.force) {
|
|
1858
|
+
return yield* new WorktreeError({
|
|
1859
|
+
message: `${validationIssue.path}: ${validationIssue.message}`,
|
|
1860
|
+
})
|
|
1861
|
+
}
|
|
1482
1862
|
}
|
|
1483
1863
|
const task = yield* tasks.show(taskId, root)
|
|
1484
1864
|
|
|
@@ -2808,6 +3188,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2808
3188
|
snapshots,
|
|
2809
3189
|
lockHeld: true,
|
|
2810
3190
|
allowReferenceDrift: true,
|
|
3191
|
+
persistResume: false,
|
|
2811
3192
|
},
|
|
2812
3193
|
)
|
|
2813
3194
|
const workspace = yield* service
|
|
@@ -2859,7 +3240,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2859
3240
|
const restored = yield* fs.runCommand(command, {
|
|
2860
3241
|
captureOutput: true,
|
|
2861
3242
|
})
|
|
2862
|
-
|
|
3243
|
+
const edited =
|
|
3244
|
+
restored.exitCode === 0 && snapshot.vcs === "jj"
|
|
3245
|
+
? yield* fs.runCommand(
|
|
3246
|
+
["jj", "-R", snapshot.path, "edit", snapshot.head],
|
|
3247
|
+
{ captureOutput: true },
|
|
3248
|
+
)
|
|
3249
|
+
: restored
|
|
3250
|
+
if (edited.exitCode === 0) rolledBack.push(snapshot.path)
|
|
2863
3251
|
else manualRecovery.push(`Restore ${snapshot.path}`)
|
|
2864
3252
|
}
|
|
2865
3253
|
return yield* new WorktreeError({
|