@markjaquith/agency 2.24.0 → 2.26.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 +14 -2
- package/cli.ts +10 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +10 -1
- package/src/cli-parser.test.ts +26 -0
- package/src/cli-parser.ts +57 -2
- package/src/commands/archive.test.ts +22 -0
- package/src/commands/archive.ts +3 -1
- package/src/commands/repo.test.ts +44 -0
- package/src/commands/repo.ts +107 -1
- package/src/commands/workbase.test.ts +47 -0
- package/src/commands/workbase.ts +52 -1
- package/src/services/ArchiveService.test.ts +59 -0
- package/src/services/ArchiveService.ts +282 -113
- package/src/services/LifecycleTransaction.test.ts +107 -0
- package/src/services/LifecycleTransaction.ts +302 -0
- package/src/services/PhaseService.ts +156 -48
- package/src/services/RepositoryService.test.ts +129 -1
- package/src/services/RepositoryService.ts +181 -0
- package/src/services/TaskPhaseService.test.ts +47 -1
- package/src/services/TaskService.ts +28 -4
- package/src/services/WorkbaseService.test.ts +46 -0
- package/src/services/WorkbaseService.ts +94 -25
- package/src/services/WorktreeLock.ts +60 -0
- package/src/services/WorktreeService.test.ts +174 -1
- package/src/services/WorktreeService.ts +972 -523
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
|
|
3
|
+
import { dirname, join, relative } from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
documentRevision,
|
|
6
|
+
RevisionConflictError,
|
|
7
|
+
} from "../workbase/document-revision"
|
|
8
|
+
|
|
9
|
+
class LifecycleTransactionError extends Data.TaggedError(
|
|
10
|
+
"LifecycleTransactionError",
|
|
11
|
+
)<{
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly completed: readonly string[]
|
|
14
|
+
readonly rolledBack: readonly string[]
|
|
15
|
+
readonly manualRecovery: readonly string[]
|
|
16
|
+
readonly cause?: unknown
|
|
17
|
+
}> {}
|
|
18
|
+
|
|
19
|
+
export interface TransactionStep {
|
|
20
|
+
readonly label: string
|
|
21
|
+
readonly preflight?: () => Promise<void>
|
|
22
|
+
readonly apply: () => Promise<void>
|
|
23
|
+
readonly rollback?: () => Promise<void>
|
|
24
|
+
readonly finalize?: () => Promise<void>
|
|
25
|
+
readonly manualRecovery?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DocumentWrite {
|
|
29
|
+
readonly path: string
|
|
30
|
+
readonly content: string
|
|
31
|
+
readonly create?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TransactionPlan {
|
|
35
|
+
readonly root: string
|
|
36
|
+
readonly preconditions?: readonly {
|
|
37
|
+
readonly path: string
|
|
38
|
+
readonly revision: string
|
|
39
|
+
}[]
|
|
40
|
+
readonly steps: readonly TransactionStep[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const exists = async (path: string) => {
|
|
44
|
+
try {
|
|
45
|
+
await lstat(path)
|
|
46
|
+
return true
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (
|
|
49
|
+
typeof error === "object" &&
|
|
50
|
+
error !== null &&
|
|
51
|
+
"code" in error &&
|
|
52
|
+
error.code === "ENOENT"
|
|
53
|
+
)
|
|
54
|
+
return false
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const documentWriteStep = (
|
|
60
|
+
root: string,
|
|
61
|
+
writes: readonly DocumentWrite[],
|
|
62
|
+
): TransactionStep => {
|
|
63
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
64
|
+
const stagingDirectory = join(root, `.agency-transaction-${token}`)
|
|
65
|
+
const staged = writes.map((write, index) => ({
|
|
66
|
+
...write,
|
|
67
|
+
stage: join(stagingDirectory, `${index}.stage`),
|
|
68
|
+
backup: join(stagingDirectory, `${index}.backup`),
|
|
69
|
+
}))
|
|
70
|
+
const installed: typeof staged = []
|
|
71
|
+
const backedUp: typeof staged = []
|
|
72
|
+
const createdDirectories: string[] = []
|
|
73
|
+
const label = `install documents: ${writes
|
|
74
|
+
.map((write) => relative(root, write.path))
|
|
75
|
+
.join(", ")}`
|
|
76
|
+
|
|
77
|
+
const rollback = async () => {
|
|
78
|
+
for (const write of [...installed].reverse()) {
|
|
79
|
+
await rm(write.path, { force: true })
|
|
80
|
+
}
|
|
81
|
+
for (const write of [...backedUp].reverse()) {
|
|
82
|
+
if (await exists(write.backup)) await rename(write.backup, write.path)
|
|
83
|
+
}
|
|
84
|
+
for (const directory of [...createdDirectories].reverse()) {
|
|
85
|
+
await rm(directory, { recursive: true, force: true })
|
|
86
|
+
}
|
|
87
|
+
installed.length = 0
|
|
88
|
+
backedUp.length = 0
|
|
89
|
+
createdDirectories.length = 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
label,
|
|
94
|
+
preflight: async () => {
|
|
95
|
+
for (const write of staged) {
|
|
96
|
+
const targetExists = await exists(write.path)
|
|
97
|
+
if (write.create === true && targetExists)
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Document already exists: ${relative(root, write.path)}`,
|
|
100
|
+
)
|
|
101
|
+
if (write.create !== true && !targetExists)
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Document does not exist: ${relative(root, write.path)}`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
apply: async () => {
|
|
108
|
+
await mkdir(stagingDirectory)
|
|
109
|
+
for (const write of staged) await Bun.write(write.stage, write.content)
|
|
110
|
+
try {
|
|
111
|
+
for (const write of staged) {
|
|
112
|
+
const parent = dirname(write.path)
|
|
113
|
+
if (!(await exists(parent))) {
|
|
114
|
+
await mkdir(parent, { recursive: true })
|
|
115
|
+
createdDirectories.push(parent)
|
|
116
|
+
}
|
|
117
|
+
if (!write.create) {
|
|
118
|
+
await rename(write.path, write.backup)
|
|
119
|
+
backedUp.push(write)
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await rename(write.stage, write.path)
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
if (!write.create && (await exists(write.backup)))
|
|
125
|
+
await rename(write.backup, write.path)
|
|
126
|
+
throw cause
|
|
127
|
+
}
|
|
128
|
+
installed.push(write)
|
|
129
|
+
}
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
try {
|
|
132
|
+
await rollback()
|
|
133
|
+
await rm(stagingDirectory, { recursive: true, force: true })
|
|
134
|
+
} catch (rollbackCause) {
|
|
135
|
+
throw new LifecycleTransactionError({
|
|
136
|
+
message: `Document installation failed and requires manual recovery: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
137
|
+
completed: [label],
|
|
138
|
+
rolledBack: [],
|
|
139
|
+
manualRecovery: [
|
|
140
|
+
`Inspect ${relative(root, stagingDirectory)} for staged documents and backups`,
|
|
141
|
+
],
|
|
142
|
+
cause: new AggregateError([cause, rollbackCause]),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
throw cause
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
rollback,
|
|
149
|
+
finalize: async () => {
|
|
150
|
+
await rm(stagingDirectory, { recursive: true, force: true })
|
|
151
|
+
},
|
|
152
|
+
manualRecovery: `Inspect ${relative(root, stagingDirectory)} for staged documents and backups`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const directoryMoveStep = (
|
|
157
|
+
root: string,
|
|
158
|
+
from: string,
|
|
159
|
+
to: string,
|
|
160
|
+
): TransactionStep => {
|
|
161
|
+
let createdParent = false
|
|
162
|
+
return {
|
|
163
|
+
label: `move ${relative(root, from)} to ${relative(root, to)}`,
|
|
164
|
+
preflight: async () => {
|
|
165
|
+
if (!(await exists(from)))
|
|
166
|
+
throw new Error(`Move source does not exist: ${relative(root, from)}`)
|
|
167
|
+
if (await exists(to))
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Move destination already exists: ${relative(root, to)}`,
|
|
170
|
+
)
|
|
171
|
+
},
|
|
172
|
+
apply: async () => {
|
|
173
|
+
const parent = dirname(to)
|
|
174
|
+
if (!(await exists(parent))) {
|
|
175
|
+
await mkdir(parent, { recursive: true })
|
|
176
|
+
createdParent = true
|
|
177
|
+
}
|
|
178
|
+
await rename(from, to)
|
|
179
|
+
},
|
|
180
|
+
rollback: async () => {
|
|
181
|
+
await rename(to, from)
|
|
182
|
+
if (createdParent) await rm(dirname(to), { recursive: true, force: true })
|
|
183
|
+
},
|
|
184
|
+
manualRecovery: `Move ${relative(root, to)} back to ${relative(root, from)}`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const runLifecycleTransaction = ({
|
|
189
|
+
root,
|
|
190
|
+
preconditions = [],
|
|
191
|
+
steps,
|
|
192
|
+
}: TransactionPlan) =>
|
|
193
|
+
Effect.tryPromise({
|
|
194
|
+
try: async () => {
|
|
195
|
+
const lockPath = join(root, ".agency-graph-mutation.lock")
|
|
196
|
+
let lock: Awaited<ReturnType<typeof open>>
|
|
197
|
+
try {
|
|
198
|
+
lock = await open(lockPath, "wx")
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
throw new LifecycleTransactionError({
|
|
201
|
+
message:
|
|
202
|
+
"Another graph mutation is in progress; wait for it to finish and retry",
|
|
203
|
+
completed: [],
|
|
204
|
+
rolledBack: [],
|
|
205
|
+
manualRecovery: [],
|
|
206
|
+
cause,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const completed: TransactionStep[] = []
|
|
211
|
+
const rolledBack: string[] = []
|
|
212
|
+
try {
|
|
213
|
+
for (const precondition of preconditions) {
|
|
214
|
+
const content = await Bun.file(precondition.path).text()
|
|
215
|
+
const currentRevision = documentRevision(content)
|
|
216
|
+
if (currentRevision !== precondition.revision) {
|
|
217
|
+
throw new RevisionConflictError({
|
|
218
|
+
path: relative(root, precondition.path),
|
|
219
|
+
expectedRevision: precondition.revision,
|
|
220
|
+
currentRevision,
|
|
221
|
+
message: `Revision conflict for ${relative(root, precondition.path)}`,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const step of steps) await step.preflight?.()
|
|
226
|
+
for (const step of steps) {
|
|
227
|
+
await step.apply()
|
|
228
|
+
completed.push(step)
|
|
229
|
+
}
|
|
230
|
+
const cleanup = await Promise.allSettled(
|
|
231
|
+
completed.map((step) => step.finalize?.() ?? Promise.resolve()),
|
|
232
|
+
)
|
|
233
|
+
const cleanupFailures = cleanup.filter(
|
|
234
|
+
(result) => result.status === "rejected",
|
|
235
|
+
)
|
|
236
|
+
if (cleanupFailures.length > 0) {
|
|
237
|
+
throw new LifecycleTransactionError({
|
|
238
|
+
message:
|
|
239
|
+
"Lifecycle mutation completed, but transaction artifacts require manual cleanup",
|
|
240
|
+
completed: completed.map((step) => step.label),
|
|
241
|
+
rolledBack: [],
|
|
242
|
+
manualRecovery: completed.flatMap((step) =>
|
|
243
|
+
step.finalize && step.manualRecovery ? [step.manualRecovery] : [],
|
|
244
|
+
),
|
|
245
|
+
cause: new AggregateError(
|
|
246
|
+
cleanupFailures.map((result) =>
|
|
247
|
+
result.status === "rejected" ? result.reason : undefined,
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
if (cause instanceof LifecycleTransactionError) throw cause
|
|
254
|
+
const rollbackErrors: unknown[] = []
|
|
255
|
+
for (const step of [...completed].reverse()) {
|
|
256
|
+
if (!step.rollback) continue
|
|
257
|
+
try {
|
|
258
|
+
await step.rollback()
|
|
259
|
+
rolledBack.push(step.label)
|
|
260
|
+
await step.finalize?.()
|
|
261
|
+
} catch (error) {
|
|
262
|
+
rollbackErrors.push(error)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const manualRecovery = completed
|
|
266
|
+
.filter(
|
|
267
|
+
(step) =>
|
|
268
|
+
!rolledBack.includes(step.label) &&
|
|
269
|
+
step.manualRecovery !== undefined,
|
|
270
|
+
)
|
|
271
|
+
.map((step) => step.manualRecovery!)
|
|
272
|
+
throw new LifecycleTransactionError({
|
|
273
|
+
message:
|
|
274
|
+
completed.length === 0
|
|
275
|
+
? `Lifecycle mutation failed before changes were applied: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
276
|
+
: rollbackErrors.length
|
|
277
|
+
? `Lifecycle mutation failed and rollback requires manual recovery: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
278
|
+
: `Lifecycle mutation failed; completed changes were rolled back: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
279
|
+
completed: completed.map((step) => step.label),
|
|
280
|
+
rolledBack,
|
|
281
|
+
manualRecovery,
|
|
282
|
+
cause: rollbackErrors.length
|
|
283
|
+
? new AggregateError([cause, ...rollbackErrors])
|
|
284
|
+
: cause,
|
|
285
|
+
})
|
|
286
|
+
} finally {
|
|
287
|
+
await lock.close().catch(() => undefined)
|
|
288
|
+
await rm(lockPath, { force: true }).catch(() => undefined)
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
catch: (cause) =>
|
|
292
|
+
cause instanceof LifecycleTransactionError ||
|
|
293
|
+
cause instanceof RevisionConflictError
|
|
294
|
+
? cause
|
|
295
|
+
: new LifecycleTransactionError({
|
|
296
|
+
message: "Lifecycle mutation failed before changes were applied",
|
|
297
|
+
completed: [],
|
|
298
|
+
rolledBack: [],
|
|
299
|
+
manualRecovery: [],
|
|
300
|
+
cause,
|
|
301
|
+
}),
|
|
302
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
2
|
import { Data, Effect, Either } from "effect"
|
|
3
|
+
import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises"
|
|
3
4
|
import { join } from "node:path"
|
|
4
5
|
import { FileSystemService } from "./FileSystemService"
|
|
5
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
@@ -18,6 +19,12 @@ import {
|
|
|
18
19
|
import { canTransitionStatus } from "../readiness"
|
|
19
20
|
import { documentRevision } from "../workbase/document-revision"
|
|
20
21
|
import { archivedPhaseDirectory } from "../workbase/archive"
|
|
22
|
+
import {
|
|
23
|
+
documentWriteStep,
|
|
24
|
+
runLifecycleTransaction,
|
|
25
|
+
type TransactionStep,
|
|
26
|
+
} from "./LifecycleTransaction"
|
|
27
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
21
28
|
|
|
22
29
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
23
30
|
readonly message: string
|
|
@@ -156,6 +163,16 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
156
163
|
]
|
|
157
164
|
: []),
|
|
158
165
|
])
|
|
166
|
+
const newAliases = [
|
|
167
|
+
data.repo,
|
|
168
|
+
...(data.repos ?? []).map((reference) => reference.repo),
|
|
169
|
+
]
|
|
170
|
+
if (new Set(newAliases).size !== newAliases.length) {
|
|
171
|
+
return yield* new PhaseError({
|
|
172
|
+
message:
|
|
173
|
+
"Repository references must be unique and cannot include the writable repository",
|
|
174
|
+
})
|
|
175
|
+
}
|
|
159
176
|
for (const alias of aliases) {
|
|
160
177
|
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
161
178
|
return yield* new PhaseError({
|
|
@@ -209,46 +226,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
209
226
|
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
210
227
|
.join(" ")
|
|
211
228
|
|
|
212
|
-
yield* fs.createDirectory(firstDirectory)
|
|
213
|
-
yield* fs.createDirectory(directory)
|
|
214
|
-
yield* fs.writeFile(
|
|
215
|
-
join(firstDirectory, "PHASE.md"),
|
|
216
|
-
formatMarkdownDocument(
|
|
217
|
-
firstData,
|
|
218
|
-
`# ${firstTitle}\n\nDescribe the phase outcome.`,
|
|
219
|
-
),
|
|
220
|
-
)
|
|
221
|
-
yield* fs.writeFile(path, content)
|
|
222
|
-
|
|
223
229
|
const oldCodePath = join(root, "tasks", taskId, "code")
|
|
224
|
-
if (yield* fs.isDirectory(oldCodePath)) {
|
|
225
|
-
const firstCodePath = join(firstDirectory, "code")
|
|
226
|
-
yield* fs.moveDirectory(oldCodePath, firstCodePath)
|
|
227
|
-
for (const alias of [
|
|
228
|
-
firstData.repo,
|
|
229
|
-
...(firstData.repos ?? []).map((reference) => reference.repo),
|
|
230
|
-
]) {
|
|
231
|
-
const checkoutPath = join(firstCodePath, alias)
|
|
232
|
-
if (!(yield* fs.isDirectory(checkoutPath))) continue
|
|
233
|
-
const repair = yield* fs.runCommand(
|
|
234
|
-
[
|
|
235
|
-
"git",
|
|
236
|
-
"-C",
|
|
237
|
-
join(root, "repos", alias),
|
|
238
|
-
"worktree",
|
|
239
|
-
"repair",
|
|
240
|
-
checkoutPath,
|
|
241
|
-
],
|
|
242
|
-
{ captureOutput: true },
|
|
243
|
-
)
|
|
244
|
-
if (repair.exitCode !== 0) {
|
|
245
|
-
return yield* new PhaseError({
|
|
246
|
-
message: `Failed to repair moved worktree for '${alias}': ${repair.stderr}`,
|
|
247
|
-
})
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
230
|
const convertedTaskData = {
|
|
253
231
|
ticketUrl: task.data.ticketUrl,
|
|
254
232
|
...(task.data.description
|
|
@@ -265,9 +243,129 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
265
243
|
},
|
|
266
244
|
],
|
|
267
245
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
246
|
+
const firstPhasePath = join(firstDirectory, "PHASE.md")
|
|
247
|
+
const firstContent = formatMarkdownDocument(
|
|
248
|
+
firstData,
|
|
249
|
+
`# ${firstTitle}\n\nDescribe the phase outcome.`,
|
|
250
|
+
)
|
|
251
|
+
const steps: TransactionStep[] = []
|
|
252
|
+
if (yield* fs.isDirectory(oldCodePath)) {
|
|
253
|
+
const firstCodePath = join(firstDirectory, "code")
|
|
254
|
+
const checkoutAliases = [
|
|
255
|
+
firstData.repo,
|
|
256
|
+
...(firstData.repos ?? []).map((reference) => reference.repo),
|
|
257
|
+
]
|
|
258
|
+
const repair = async (basePath: string) => {
|
|
259
|
+
for (const alias of checkoutAliases) {
|
|
260
|
+
const checkoutPath = join(basePath, alias)
|
|
261
|
+
try {
|
|
262
|
+
await lstat(checkoutPath)
|
|
263
|
+
} catch {
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const result = Bun.spawnSync([
|
|
267
|
+
"git",
|
|
268
|
+
"-C",
|
|
269
|
+
join(root, "repos", alias),
|
|
270
|
+
"worktree",
|
|
271
|
+
"repair",
|
|
272
|
+
checkoutPath,
|
|
273
|
+
])
|
|
274
|
+
if (result.exitCode !== 0) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
steps.push({
|
|
282
|
+
label: `move and repair code for ${taskId}/${firstPhaseId}`,
|
|
283
|
+
preflight: async () => {
|
|
284
|
+
for (const entry of await readdir(oldCodePath)) {
|
|
285
|
+
if (!checkoutAliases.includes(entry))
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
for (const alias of checkoutAliases) {
|
|
291
|
+
const checkoutPath = join(oldCodePath, alias)
|
|
292
|
+
try {
|
|
293
|
+
await lstat(checkoutPath)
|
|
294
|
+
} catch {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
const listed = Bun.spawnSync([
|
|
298
|
+
"git",
|
|
299
|
+
"-C",
|
|
300
|
+
join(root, "repos", alias),
|
|
301
|
+
"worktree",
|
|
302
|
+
"list",
|
|
303
|
+
"--porcelain",
|
|
304
|
+
])
|
|
305
|
+
if (listed.exitCode !== 0)
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Failed to inspect worktrees for '${alias}'`,
|
|
308
|
+
)
|
|
309
|
+
const expected = await realpath(checkoutPath)
|
|
310
|
+
let registered = false
|
|
311
|
+
for (const line of new TextDecoder()
|
|
312
|
+
.decode(listed.stdout)
|
|
313
|
+
.split("\n")) {
|
|
314
|
+
if (!line.startsWith("worktree ")) continue
|
|
315
|
+
try {
|
|
316
|
+
if ((await realpath(line.slice(9))) === expected) {
|
|
317
|
+
registered = true
|
|
318
|
+
break
|
|
319
|
+
}
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
322
|
+
if (!registered)
|
|
323
|
+
throw new Error(
|
|
324
|
+
`Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
apply: async () => {
|
|
329
|
+
await mkdir(firstDirectory, { recursive: true })
|
|
330
|
+
await rename(oldCodePath, firstCodePath)
|
|
331
|
+
try {
|
|
332
|
+
await repair(firstCodePath)
|
|
333
|
+
} catch (cause) {
|
|
334
|
+
await rename(firstCodePath, oldCodePath)
|
|
335
|
+
await repair(oldCodePath)
|
|
336
|
+
await rm(firstDirectory, { recursive: true, force: true })
|
|
337
|
+
throw cause
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
rollback: async () => {
|
|
341
|
+
await rename(firstCodePath, oldCodePath)
|
|
342
|
+
await repair(oldCodePath)
|
|
343
|
+
await rm(firstDirectory, { recursive: true, force: true })
|
|
344
|
+
},
|
|
345
|
+
manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
steps.push(
|
|
349
|
+
documentWriteStep(root, [
|
|
350
|
+
{ path: firstPhasePath, content: firstContent, create: true },
|
|
351
|
+
{ path, content, create: true },
|
|
352
|
+
{
|
|
353
|
+
path: task.path,
|
|
354
|
+
content: formatMarkdownDocument(
|
|
355
|
+
convertedTaskData,
|
|
356
|
+
parsedTask.body,
|
|
357
|
+
),
|
|
358
|
+
},
|
|
359
|
+
]),
|
|
360
|
+
)
|
|
361
|
+
yield* withWorktreeLocks(
|
|
362
|
+
root,
|
|
363
|
+
[{ taskId }],
|
|
364
|
+
runLifecycleTransaction({
|
|
365
|
+
root,
|
|
366
|
+
preconditions: [{ path: task.path, revision: task.revision }],
|
|
367
|
+
steps,
|
|
368
|
+
}),
|
|
271
369
|
)
|
|
272
370
|
return {
|
|
273
371
|
taskId,
|
|
@@ -279,8 +377,6 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
279
377
|
} satisfies PhaseRecord
|
|
280
378
|
}
|
|
281
379
|
|
|
282
|
-
yield* fs.createDirectory(directory)
|
|
283
|
-
yield* fs.writeFile(path, content)
|
|
284
380
|
const updatedTaskData = {
|
|
285
381
|
...task.data,
|
|
286
382
|
phases: [
|
|
@@ -293,10 +389,22 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
293
389
|
},
|
|
294
390
|
],
|
|
295
391
|
}
|
|
296
|
-
yield*
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
392
|
+
yield* runLifecycleTransaction({
|
|
393
|
+
root,
|
|
394
|
+
preconditions: [{ path: task.path, revision: task.revision }],
|
|
395
|
+
steps: [
|
|
396
|
+
documentWriteStep(root, [
|
|
397
|
+
{ path, content, create: true },
|
|
398
|
+
{
|
|
399
|
+
path: task.path,
|
|
400
|
+
content: formatMarkdownDocument(
|
|
401
|
+
updatedTaskData,
|
|
402
|
+
parsedTask.body,
|
|
403
|
+
),
|
|
404
|
+
},
|
|
405
|
+
]),
|
|
406
|
+
],
|
|
407
|
+
})
|
|
300
408
|
|
|
301
409
|
return {
|
|
302
410
|
taskId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { mkdir } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { RepositoryService } from "./RepositoryService"
|
|
7
7
|
|
|
@@ -16,6 +16,12 @@ const runGit = async (args: string[]) => {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const write = async (root: string, path: string, content: string) => {
|
|
20
|
+
const fullPath = join(root, path)
|
|
21
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
22
|
+
await Bun.write(fullPath, content)
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
describe("RepositoryService", () => {
|
|
20
26
|
let root: string
|
|
21
27
|
|
|
@@ -103,4 +109,126 @@ describe("RepositoryService", () => {
|
|
|
103
109
|
),
|
|
104
110
|
).rejects.toThrow("already exists")
|
|
105
111
|
})
|
|
112
|
+
|
|
113
|
+
test("shows, fetches, updates, and verifies a repository", async () => {
|
|
114
|
+
const source = join(root, "source")
|
|
115
|
+
const replacement = join(root, "replacement.git")
|
|
116
|
+
await runGit(["init", "--initial-branch=main", source])
|
|
117
|
+
await Bun.write(join(source, "README.md"), "# Source\n")
|
|
118
|
+
await runGit(["-C", source, "add", "README.md"])
|
|
119
|
+
await runGit([
|
|
120
|
+
"-C",
|
|
121
|
+
source,
|
|
122
|
+
"-c",
|
|
123
|
+
"user.name=Agency Tests",
|
|
124
|
+
"-c",
|
|
125
|
+
"user.email=agency@example.com",
|
|
126
|
+
"commit",
|
|
127
|
+
"-m",
|
|
128
|
+
"Initial commit",
|
|
129
|
+
])
|
|
130
|
+
await runGit(["init", "--bare", "--initial-branch=main", replacement])
|
|
131
|
+
await runTestEffect(
|
|
132
|
+
RepositoryService.pipe(
|
|
133
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const result = await runTestEffect(
|
|
138
|
+
RepositoryService.pipe(
|
|
139
|
+
Effect.flatMap((service) =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
const shown = yield* service.show("agency", root)
|
|
142
|
+
yield* service.fetch("agency", root)
|
|
143
|
+
const updated = yield* service.remote("agency", replacement, root)
|
|
144
|
+
const verified = yield* service.verify("agency", root)
|
|
145
|
+
return { shown, updated, verified }
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(result.shown.remote).toBe(source)
|
|
152
|
+
expect(result.updated.remote).toBe(replacement)
|
|
153
|
+
expect(result.verified.valid).toBe(true)
|
|
154
|
+
expect(result.verified.issues).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("renames and removes an unused repository", async () => {
|
|
158
|
+
const source = join(root, "source.git")
|
|
159
|
+
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
160
|
+
await runTestEffect(
|
|
161
|
+
RepositoryService.pipe(
|
|
162
|
+
Effect.flatMap((service) => service.add("old", source, root)),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const removed = await runTestEffect(
|
|
167
|
+
RepositoryService.pipe(
|
|
168
|
+
Effect.flatMap((service) =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const renamed = yield* service.rename("old", "new", root)
|
|
171
|
+
expect(renamed.alias).toBe("new")
|
|
172
|
+
return yield* service.remove("new", root)
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
expect(removed.alias).toBe("new")
|
|
179
|
+
expect(await Bun.file(join(root, "repos/new/HEAD")).exists()).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("unlinks a symlink without deleting its target", async () => {
|
|
183
|
+
const target = join(root, "linked-repository")
|
|
184
|
+
await mkdir(target, { recursive: true })
|
|
185
|
+
await runGit(["init", "--initial-branch=main", target])
|
|
186
|
+
await runTestEffect(
|
|
187
|
+
RepositoryService.pipe(
|
|
188
|
+
Effect.flatMap((service) => service.link("linked", target, root)),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
await runTestEffect(
|
|
193
|
+
RepositoryService.pipe(
|
|
194
|
+
Effect.flatMap((service) => service.unlink("linked", root)),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
|
|
199
|
+
expect(await Bun.file(join(root, "repos/linked/.git/HEAD")).exists()).toBe(
|
|
200
|
+
false,
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("reports active references and refuses unsafe removal", async () => {
|
|
205
|
+
const source = join(root, "source.git")
|
|
206
|
+
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
207
|
+
await runTestEffect(
|
|
208
|
+
RepositoryService.pipe(
|
|
209
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
await write(
|
|
213
|
+
root,
|
|
214
|
+
"tasks/active/TASK.md",
|
|
215
|
+
`---
|
|
216
|
+
ticketUrl: null
|
|
217
|
+
repo: agency
|
|
218
|
+
branch: task/active
|
|
219
|
+
base: main
|
|
220
|
+
pr: null
|
|
221
|
+
---
|
|
222
|
+
`,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
runTestEffect(
|
|
227
|
+
RepositoryService.pipe(
|
|
228
|
+
Effect.flatMap((service) => service.remove("agency", root)),
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
).rejects.toThrow("active reference execution-unit:task/active")
|
|
232
|
+
expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
|
|
233
|
+
})
|
|
106
234
|
})
|