@markjaquith/agency 2.15.0 → 2.16.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 +20 -1
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +15 -0
- package/src/cli-parser.ts +15 -0
- package/src/commands/sync.ts +36 -0
- package/src/protocol.ts +5 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/test-utils.ts +2 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
4
|
+
import type {
|
|
5
|
+
ClaimRecord,
|
|
6
|
+
PhaseFrontmatter,
|
|
7
|
+
RepositoryReference,
|
|
8
|
+
TaskFrontmatter,
|
|
9
|
+
WorkStatus,
|
|
10
|
+
} from "../workbase/schemas"
|
|
11
|
+
import { ClaimService } from "./ClaimService"
|
|
12
|
+
import { FileSystemService } from "./FileSystemService"
|
|
13
|
+
import { PhaseService } from "./PhaseService"
|
|
14
|
+
import { TaskService } from "./TaskService"
|
|
15
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
16
|
+
import { WorktreeService } from "./WorktreeService"
|
|
17
|
+
|
|
18
|
+
class SyncError extends Data.TaggedError("SyncError")<{
|
|
19
|
+
readonly message: string
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
type ExecutionData =
|
|
23
|
+
| PhaseFrontmatter
|
|
24
|
+
| Extract<TaskFrontmatter, { readonly repo: string }>
|
|
25
|
+
|
|
26
|
+
interface ExecutionRecord {
|
|
27
|
+
readonly key: string
|
|
28
|
+
readonly taskId: string
|
|
29
|
+
readonly phaseId?: string
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly revision: string
|
|
32
|
+
readonly data: ExecutionData
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RegisteredWorktree {
|
|
36
|
+
readonly path: string
|
|
37
|
+
readonly head: string | null
|
|
38
|
+
readonly branch: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SyncChange {
|
|
42
|
+
readonly kind:
|
|
43
|
+
| "materialize-workspace"
|
|
44
|
+
| "release-stale-claim"
|
|
45
|
+
| "record-pr"
|
|
46
|
+
| "mark-done"
|
|
47
|
+
readonly target: string
|
|
48
|
+
readonly message: string
|
|
49
|
+
readonly status: "planned" | "applied"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SyncNotice {
|
|
53
|
+
readonly kind: string
|
|
54
|
+
readonly target: string
|
|
55
|
+
readonly message: string
|
|
56
|
+
readonly action?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface CheckoutState {
|
|
60
|
+
readonly repo: string
|
|
61
|
+
readonly kind: "writable" | "reference"
|
|
62
|
+
readonly path: string
|
|
63
|
+
readonly requestedRef: string
|
|
64
|
+
readonly resolvedCommit: string | null
|
|
65
|
+
readonly registered: boolean
|
|
66
|
+
readonly exists: boolean
|
|
67
|
+
readonly head: string | null
|
|
68
|
+
readonly branch: string | null
|
|
69
|
+
readonly dirty: boolean | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ExecutionSyncState {
|
|
73
|
+
readonly target: string
|
|
74
|
+
readonly status: WorkStatus
|
|
75
|
+
readonly branch: string
|
|
76
|
+
readonly base: string
|
|
77
|
+
readonly claim: ClaimRecord | null
|
|
78
|
+
readonly checkouts: readonly CheckoutState[]
|
|
79
|
+
readonly pr: Record<string, unknown>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface SyncResult {
|
|
83
|
+
readonly root: string
|
|
84
|
+
readonly mode: "dry-run" | "apply"
|
|
85
|
+
readonly changes: readonly SyncChange[]
|
|
86
|
+
readonly warnings: readonly SyncNotice[]
|
|
87
|
+
readonly unresolved: readonly SyncNotice[]
|
|
88
|
+
readonly executions: readonly ExecutionSyncState[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const parseWorktrees = (output: string): RegisteredWorktree[] => {
|
|
92
|
+
const worktrees: RegisteredWorktree[] = []
|
|
93
|
+
let current: RegisteredWorktree | undefined
|
|
94
|
+
for (const field of output.split("\0")) {
|
|
95
|
+
if (field.startsWith("worktree ")) {
|
|
96
|
+
if (current) worktrees.push(current)
|
|
97
|
+
current = {
|
|
98
|
+
path: field.slice("worktree ".length),
|
|
99
|
+
head: null,
|
|
100
|
+
branch: null,
|
|
101
|
+
}
|
|
102
|
+
} else if (current && field.startsWith("HEAD ")) {
|
|
103
|
+
current = { ...current, head: field.slice("HEAD ".length) }
|
|
104
|
+
} else if (current && field.startsWith("branch ")) {
|
|
105
|
+
current = { ...current, branch: field.slice("branch ".length) }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (current) worktrees.push(current)
|
|
109
|
+
return worktrees
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parseJson = <T>(value: string, fallback: T): T => {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(value) as T
|
|
115
|
+
} catch {
|
|
116
|
+
return fallback
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isExpired = (claim: ClaimRecord | undefined, now: Date) =>
|
|
121
|
+
claim?.state === "active" &&
|
|
122
|
+
claim.expiresAt !== undefined &&
|
|
123
|
+
Date.parse(claim.expiresAt) <= now.getTime()
|
|
124
|
+
|
|
125
|
+
const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
|
|
126
|
+
|
|
127
|
+
const originRef = (ref: string) =>
|
|
128
|
+
ref.replace(/^refs\/remotes\/origin\//, "").replace(/^origin\//, "")
|
|
129
|
+
|
|
130
|
+
export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
131
|
+
sync: () => ({
|
|
132
|
+
reconcile: (
|
|
133
|
+
options: {
|
|
134
|
+
readonly cwd?: string
|
|
135
|
+
readonly apply?: boolean
|
|
136
|
+
readonly now?: Date
|
|
137
|
+
} = {},
|
|
138
|
+
) =>
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
const fs = yield* FileSystemService
|
|
141
|
+
const workbase = yield* WorkbaseService
|
|
142
|
+
const tasks = yield* TaskService
|
|
143
|
+
const phases = yield* PhaseService
|
|
144
|
+
const worktrees = yield* WorktreeService
|
|
145
|
+
const claims = yield* ClaimService
|
|
146
|
+
const root = yield* workbase.discover(options.cwd)
|
|
147
|
+
const validation = yield* workbase.validate(root)
|
|
148
|
+
if (!validation.valid) {
|
|
149
|
+
return yield* new SyncError({
|
|
150
|
+
message: validation.issues
|
|
151
|
+
.map((issue) => `${issue.path}: ${issue.message}`)
|
|
152
|
+
.join("\n"),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const apply = options.apply === true
|
|
157
|
+
const now = options.now ?? new Date()
|
|
158
|
+
const changes: SyncChange[] = []
|
|
159
|
+
const warnings: SyncNotice[] = []
|
|
160
|
+
const unresolved: SyncNotice[] = []
|
|
161
|
+
const executions: ExecutionSyncState[] = []
|
|
162
|
+
const runExternal = (
|
|
163
|
+
args: readonly string[],
|
|
164
|
+
commandOptions?: { readonly cwd?: string },
|
|
165
|
+
) =>
|
|
166
|
+
fs
|
|
167
|
+
.runCommand(args, {
|
|
168
|
+
cwd: commandOptions?.cwd,
|
|
169
|
+
captureOutput: true,
|
|
170
|
+
})
|
|
171
|
+
.pipe(
|
|
172
|
+
Effect.catchAll((error) =>
|
|
173
|
+
Effect.succeed({
|
|
174
|
+
exitCode: -1,
|
|
175
|
+
stdout: "",
|
|
176
|
+
stderr: error.message,
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
const records: ExecutionRecord[] = []
|
|
181
|
+
for (const task of yield* tasks.list(root)) {
|
|
182
|
+
if ("phases" in task.data) {
|
|
183
|
+
for (const phase of yield* phases.list(task.id, root)) {
|
|
184
|
+
records.push({
|
|
185
|
+
key: `phase:${task.id}/${phase.id}`,
|
|
186
|
+
taskId: task.id,
|
|
187
|
+
phaseId: phase.id,
|
|
188
|
+
path: phase.path,
|
|
189
|
+
revision: documentRevision(phase.content),
|
|
190
|
+
data: phase.data,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
records.push({
|
|
195
|
+
key: `task:${task.id}`,
|
|
196
|
+
taskId: task.id,
|
|
197
|
+
path: task.path,
|
|
198
|
+
revision: documentRevision(task.content),
|
|
199
|
+
data: task.data,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const record of records.sort((a, b) =>
|
|
205
|
+
a.key.localeCompare(b.key),
|
|
206
|
+
)) {
|
|
207
|
+
let data = record.data
|
|
208
|
+
let revision = record.revision
|
|
209
|
+
const codePath = join(dirname(record.path), "code")
|
|
210
|
+
const checkoutStates: CheckoutState[] = []
|
|
211
|
+
let materialize = false
|
|
212
|
+
let workspaceConflict = false
|
|
213
|
+
const declared: readonly (
|
|
214
|
+
| { readonly repo: string; readonly branch: string }
|
|
215
|
+
| RepositoryReference
|
|
216
|
+
)[] = [
|
|
217
|
+
{ repo: data.repo, branch: data.branch },
|
|
218
|
+
...(data.repos ?? []),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
for (const checkout of declared) {
|
|
222
|
+
const repositoryPath = join(root, "repos", checkout.repo)
|
|
223
|
+
const checkoutPath = join(codePath, checkout.repo)
|
|
224
|
+
const kind = "branch" in checkout ? "writable" : "reference"
|
|
225
|
+
const requestedRef =
|
|
226
|
+
"branch" in checkout ? checkout.branch : checkout.ref
|
|
227
|
+
if (!(yield* fs.exists(repositoryPath))) {
|
|
228
|
+
unresolved.push({
|
|
229
|
+
kind: "missing-repository",
|
|
230
|
+
target: record.key,
|
|
231
|
+
message: `Repository alias '${checkout.repo}' is missing`,
|
|
232
|
+
action: "Restore or relink the repository alias",
|
|
233
|
+
})
|
|
234
|
+
workspaceConflict = true
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
const listed = yield* fs.runCommand(
|
|
238
|
+
[
|
|
239
|
+
"git",
|
|
240
|
+
"-C",
|
|
241
|
+
repositoryPath,
|
|
242
|
+
"worktree",
|
|
243
|
+
"list",
|
|
244
|
+
"--porcelain",
|
|
245
|
+
"-z",
|
|
246
|
+
],
|
|
247
|
+
{ captureOutput: true },
|
|
248
|
+
)
|
|
249
|
+
if (listed.exitCode !== 0) {
|
|
250
|
+
unresolved.push({
|
|
251
|
+
kind: "worktree-inspection-failed",
|
|
252
|
+
target: record.key,
|
|
253
|
+
message:
|
|
254
|
+
listed.stderr.trim() || `Cannot inspect '${checkout.repo}'`,
|
|
255
|
+
})
|
|
256
|
+
workspaceConflict = true
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const exists = yield* fs.isDirectory(checkoutPath)
|
|
260
|
+
const registered: RegisteredWorktree[] = []
|
|
261
|
+
for (const item of parseWorktrees(listed.stdout)) {
|
|
262
|
+
registered.push({
|
|
263
|
+
...item,
|
|
264
|
+
path: (yield* fs.exists(item.path))
|
|
265
|
+
? yield* fs.realPath(item.path)
|
|
266
|
+
: resolve(item.path),
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
const expectedPath = exists
|
|
270
|
+
? yield* fs.realPath(checkoutPath)
|
|
271
|
+
: (yield* fs.isDirectory(codePath))
|
|
272
|
+
? join(yield* fs.realPath(codePath), checkout.repo)
|
|
273
|
+
: resolve(checkoutPath)
|
|
274
|
+
let atPath = registered.find((item) => item.path === expectedPath)
|
|
275
|
+
const branchRef =
|
|
276
|
+
"branch" in checkout ? `refs/heads/${checkout.branch}` : null
|
|
277
|
+
const branchElsewhere = branchRef
|
|
278
|
+
? registered.find(
|
|
279
|
+
(item) =>
|
|
280
|
+
item.branch === branchRef && item.path !== expectedPath,
|
|
281
|
+
)
|
|
282
|
+
: undefined
|
|
283
|
+
if ("branch" in checkout && !exists && !branchElsewhere) {
|
|
284
|
+
const branch = yield* fs.runCommand(
|
|
285
|
+
[
|
|
286
|
+
"git",
|
|
287
|
+
"-C",
|
|
288
|
+
repositoryPath,
|
|
289
|
+
"rev-parse",
|
|
290
|
+
"--verify",
|
|
291
|
+
`${checkout.branch}^{commit}`,
|
|
292
|
+
],
|
|
293
|
+
{ captureOutput: true },
|
|
294
|
+
)
|
|
295
|
+
if (branch.exitCode !== 0) {
|
|
296
|
+
const base = yield* fs.runCommand(
|
|
297
|
+
[
|
|
298
|
+
"git",
|
|
299
|
+
"-C",
|
|
300
|
+
repositoryPath,
|
|
301
|
+
"rev-parse",
|
|
302
|
+
"--verify",
|
|
303
|
+
`${data.base}^{commit}`,
|
|
304
|
+
],
|
|
305
|
+
{ captureOutput: true },
|
|
306
|
+
)
|
|
307
|
+
if (base.exitCode !== 0) {
|
|
308
|
+
unresolved.push({
|
|
309
|
+
kind: "unresolved-base",
|
|
310
|
+
target: record.key,
|
|
311
|
+
message: `Neither branch '${checkout.branch}' nor base '${data.base}' resolves locally`,
|
|
312
|
+
action: "Fetch or correct the declared branch and base",
|
|
313
|
+
})
|
|
314
|
+
workspaceConflict = true
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (atPath && !exists) {
|
|
320
|
+
unresolved.push({
|
|
321
|
+
kind: "stale-registration",
|
|
322
|
+
target: record.key,
|
|
323
|
+
message: `Worktree registry points to missing checkout ${checkoutPath}`,
|
|
324
|
+
action:
|
|
325
|
+
"Remove the stale registration after confirming the checkout cannot be restored",
|
|
326
|
+
})
|
|
327
|
+
workspaceConflict = true
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (branchElsewhere) {
|
|
331
|
+
unresolved.push({
|
|
332
|
+
kind: "branch-conflict",
|
|
333
|
+
target: record.key,
|
|
334
|
+
message: `Branch '${requestedRef}' is checked out at ${branchElsewhere.path}`,
|
|
335
|
+
action: "Remove or relocate the conflicting worktree",
|
|
336
|
+
})
|
|
337
|
+
workspaceConflict = true
|
|
338
|
+
}
|
|
339
|
+
if (exists && !atPath) {
|
|
340
|
+
unresolved.push({
|
|
341
|
+
kind: "unregistered-checkout",
|
|
342
|
+
target: record.key,
|
|
343
|
+
message: `${checkoutPath} exists but is not registered as a worktree`,
|
|
344
|
+
action:
|
|
345
|
+
"Move the unmanaged checkout or repair its registration",
|
|
346
|
+
})
|
|
347
|
+
workspaceConflict = true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let resolvedCommit: string | null = null
|
|
351
|
+
if ("ref" in checkout) {
|
|
352
|
+
if (!isCommitId(checkout.ref)) {
|
|
353
|
+
const remoteRef = yield* runExternal([
|
|
354
|
+
"git",
|
|
355
|
+
"-C",
|
|
356
|
+
repositoryPath,
|
|
357
|
+
"ls-remote",
|
|
358
|
+
"origin",
|
|
359
|
+
originRef(checkout.ref),
|
|
360
|
+
])
|
|
361
|
+
resolvedCommit =
|
|
362
|
+
remoteRef.stdout.match(/^([0-9a-f]{40,64})\s/m)?.[1] ?? null
|
|
363
|
+
if (!resolvedCommit) {
|
|
364
|
+
warnings.push({
|
|
365
|
+
kind: "reference-remote-unavailable",
|
|
366
|
+
target: record.key,
|
|
367
|
+
message: `Could not inspect remote reference '${checkout.ref}' for '${checkout.repo}'`,
|
|
368
|
+
action:
|
|
369
|
+
"Verify remote access before applying reference changes",
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const resolvedRef = resolvedCommit
|
|
374
|
+
? null
|
|
375
|
+
: yield* fs.runCommand(
|
|
376
|
+
[
|
|
377
|
+
"git",
|
|
378
|
+
"-C",
|
|
379
|
+
repositoryPath,
|
|
380
|
+
"rev-parse",
|
|
381
|
+
"--verify",
|
|
382
|
+
`${checkout.ref}^{commit}`,
|
|
383
|
+
],
|
|
384
|
+
{ captureOutput: true },
|
|
385
|
+
)
|
|
386
|
+
if (resolvedRef?.exitCode === 0) {
|
|
387
|
+
resolvedCommit = resolvedRef.stdout.trim()
|
|
388
|
+
}
|
|
389
|
+
if (!resolvedCommit) {
|
|
390
|
+
unresolved.push({
|
|
391
|
+
kind: "unresolved-reference",
|
|
392
|
+
target: record.key,
|
|
393
|
+
message: `Reference '${checkout.ref}' for '${checkout.repo}' does not resolve locally`,
|
|
394
|
+
action: "Fetch or correct the declared reference",
|
|
395
|
+
})
|
|
396
|
+
workspaceConflict = true
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const dirtyResult =
|
|
401
|
+
exists && atPath
|
|
402
|
+
? yield* fs.runCommand(
|
|
403
|
+
["git", "-C", checkoutPath, "status", "--porcelain"],
|
|
404
|
+
{ captureOutput: true },
|
|
405
|
+
)
|
|
406
|
+
: null
|
|
407
|
+
const dirty = dirtyResult
|
|
408
|
+
? dirtyResult.exitCode === 0
|
|
409
|
+
? dirtyResult.stdout.length > 0
|
|
410
|
+
: null
|
|
411
|
+
: null
|
|
412
|
+
if (dirtyResult && dirtyResult.exitCode !== 0) {
|
|
413
|
+
warnings.push({
|
|
414
|
+
kind: "status-inspection-failed",
|
|
415
|
+
target: record.key,
|
|
416
|
+
message: `Could not inspect dirtiness for ${checkoutPath}: ${dirtyResult.stderr.trim()}`,
|
|
417
|
+
action: "Inspect the checkout manually before changing it",
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
if (dirty) {
|
|
421
|
+
warnings.push({
|
|
422
|
+
kind:
|
|
423
|
+
kind === "reference" ? "dirty-reference" : "dirty-writable",
|
|
424
|
+
target: record.key,
|
|
425
|
+
message: `${kind === "reference" ? "Reference" : "Writable"} checkout ${checkoutPath} is dirty`,
|
|
426
|
+
action: "Review and preserve or discard local changes manually",
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
if (atPath && branchRef && atPath.branch !== branchRef) {
|
|
430
|
+
unresolved.push({
|
|
431
|
+
kind: "wrong-branch",
|
|
432
|
+
target: record.key,
|
|
433
|
+
message: `${checkoutPath} is not registered to '${requestedRef}'`,
|
|
434
|
+
action: "Repair the writable worktree manually",
|
|
435
|
+
})
|
|
436
|
+
workspaceConflict = true
|
|
437
|
+
}
|
|
438
|
+
if (atPath && "ref" in checkout) {
|
|
439
|
+
if (atPath.branch) {
|
|
440
|
+
unresolved.push({
|
|
441
|
+
kind: "attached-reference",
|
|
442
|
+
target: record.key,
|
|
443
|
+
message: `Reference checkout ${checkoutPath} is attached to ${atPath.branch}`,
|
|
444
|
+
})
|
|
445
|
+
workspaceConflict = true
|
|
446
|
+
} else if (resolvedCommit && atPath.head !== resolvedCommit) {
|
|
447
|
+
unresolved.push({
|
|
448
|
+
kind: "reference-drift",
|
|
449
|
+
target: record.key,
|
|
450
|
+
message: `${checkoutPath} is at ${atPath.head}, expected ${resolvedCommit}`,
|
|
451
|
+
action: dirty
|
|
452
|
+
? "Preserve or discard local changes before recreating the checkout"
|
|
453
|
+
: "Recreate the reference checkout",
|
|
454
|
+
})
|
|
455
|
+
workspaceConflict = true
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!exists && !atPath && !branchElsewhere) materialize = true
|
|
459
|
+
checkoutStates.push({
|
|
460
|
+
repo: checkout.repo,
|
|
461
|
+
kind,
|
|
462
|
+
path: checkoutPath,
|
|
463
|
+
requestedRef,
|
|
464
|
+
resolvedCommit,
|
|
465
|
+
registered: Boolean(atPath),
|
|
466
|
+
exists,
|
|
467
|
+
head: atPath?.head ?? null,
|
|
468
|
+
branch: atPath?.branch?.replace(/^refs\/heads\//, "") ?? null,
|
|
469
|
+
dirty,
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (materialize && !workspaceConflict) {
|
|
474
|
+
if (apply) {
|
|
475
|
+
const workspace = yield* worktrees.materialize(
|
|
476
|
+
record.taskId,
|
|
477
|
+
record.phaseId,
|
|
478
|
+
root,
|
|
479
|
+
{ silent: true },
|
|
480
|
+
)
|
|
481
|
+
for (const checkout of workspace.checkouts) {
|
|
482
|
+
const index = checkoutStates.findIndex(
|
|
483
|
+
(item) => item.repo === checkout.repo,
|
|
484
|
+
)
|
|
485
|
+
if (index >= 0) {
|
|
486
|
+
const previous = checkoutStates[index]!
|
|
487
|
+
checkoutStates[index] = {
|
|
488
|
+
...previous,
|
|
489
|
+
exists: true,
|
|
490
|
+
registered: true,
|
|
491
|
+
resolvedCommit: checkout.resolvedCommit,
|
|
492
|
+
head: checkout.resolvedCommit,
|
|
493
|
+
branch:
|
|
494
|
+
checkout.kind === "writable"
|
|
495
|
+
? checkout.requestedRef
|
|
496
|
+
: null,
|
|
497
|
+
dirty: false,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
changes.push({
|
|
503
|
+
kind: "materialize-workspace",
|
|
504
|
+
target: record.key,
|
|
505
|
+
message: `Materialize missing checkouts under ${codePath}`,
|
|
506
|
+
status: apply ? "applied" : "planned",
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (
|
|
511
|
+
isExpired(data.claim, now) &&
|
|
512
|
+
(data.status === "working" || data.status === "delegated")
|
|
513
|
+
) {
|
|
514
|
+
const sessionId = data.claim!.sessionId
|
|
515
|
+
if (apply) {
|
|
516
|
+
const expired = yield* claims.expire(
|
|
517
|
+
{
|
|
518
|
+
taskId: record.taskId,
|
|
519
|
+
phaseId: record.phaseId,
|
|
520
|
+
revision,
|
|
521
|
+
now,
|
|
522
|
+
},
|
|
523
|
+
root,
|
|
524
|
+
)
|
|
525
|
+
data = expired.data
|
|
526
|
+
revision = expired.revision
|
|
527
|
+
} else {
|
|
528
|
+
const claim: ClaimRecord = {
|
|
529
|
+
...data.claim!,
|
|
530
|
+
state: "released",
|
|
531
|
+
releasedAt: now.toISOString(),
|
|
532
|
+
}
|
|
533
|
+
data = { ...data, status: "open", claim }
|
|
534
|
+
}
|
|
535
|
+
changes.push({
|
|
536
|
+
kind: "release-stale-claim",
|
|
537
|
+
target: record.key,
|
|
538
|
+
message: `Release expired claim '${sessionId}'`,
|
|
539
|
+
status: apply ? "applied" : "planned",
|
|
540
|
+
})
|
|
541
|
+
} else if (
|
|
542
|
+
data.claim?.state === "active" &&
|
|
543
|
+
data.status !== "working" &&
|
|
544
|
+
data.status !== "delegated"
|
|
545
|
+
) {
|
|
546
|
+
unresolved.push({
|
|
547
|
+
kind: "claim-status-conflict",
|
|
548
|
+
target: record.key,
|
|
549
|
+
message: `Active claim conflicts with '${data.status}' status`,
|
|
550
|
+
action: "Release or finish the claim explicitly",
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let pr: Record<string, unknown> = { url: data.pr, state: "none" }
|
|
555
|
+
let prConflict = false
|
|
556
|
+
if (data.pr) {
|
|
557
|
+
const remote = yield* runExternal([
|
|
558
|
+
"git",
|
|
559
|
+
"-C",
|
|
560
|
+
join(root, "repos", data.repo),
|
|
561
|
+
"remote",
|
|
562
|
+
"get-url",
|
|
563
|
+
"origin",
|
|
564
|
+
])
|
|
565
|
+
const remoteRepository = remote.stdout
|
|
566
|
+
.trim()
|
|
567
|
+
.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/)?.[1]
|
|
568
|
+
?.replace(/\.git$/, "")
|
|
569
|
+
const prRepository = data.pr.match(
|
|
570
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+\/?$/,
|
|
571
|
+
)?.[1]
|
|
572
|
+
if (
|
|
573
|
+
!remoteRepository ||
|
|
574
|
+
remoteRepository.toLowerCase() !== prRepository?.toLowerCase()
|
|
575
|
+
) {
|
|
576
|
+
prConflict = true
|
|
577
|
+
unresolved.push({
|
|
578
|
+
kind: "pr-repository-conflict",
|
|
579
|
+
target: record.key,
|
|
580
|
+
message:
|
|
581
|
+
"Recorded PR repository does not match the writable repository origin",
|
|
582
|
+
action: "Correct the repository origin or recorded PR URL",
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
const viewed = yield* runExternal([
|
|
586
|
+
"gh",
|
|
587
|
+
"pr",
|
|
588
|
+
"view",
|
|
589
|
+
data.pr,
|
|
590
|
+
"--json",
|
|
591
|
+
"number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
|
|
592
|
+
])
|
|
593
|
+
if (viewed.exitCode === 0) {
|
|
594
|
+
pr = parseJson(viewed.stdout, pr)
|
|
595
|
+
if (
|
|
596
|
+
pr.headRefName !== data.branch ||
|
|
597
|
+
pr.baseRefName !== data.base
|
|
598
|
+
) {
|
|
599
|
+
prConflict = true
|
|
600
|
+
unresolved.push({
|
|
601
|
+
kind: "pr-branch-conflict",
|
|
602
|
+
target: record.key,
|
|
603
|
+
message: `Recorded PR branches do not match '${data.branch}' -> '${data.base}'`,
|
|
604
|
+
action: "Correct the declaration or recorded PR URL",
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
pr = { url: data.pr, state: "unavailable" }
|
|
609
|
+
warnings.push({
|
|
610
|
+
kind: "pr-unavailable",
|
|
611
|
+
target: record.key,
|
|
612
|
+
message: `Could not inspect ${data.pr}: ${viewed.stderr.trim()}`,
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
const listed = yield* runExternal(
|
|
617
|
+
[
|
|
618
|
+
"gh",
|
|
619
|
+
"pr",
|
|
620
|
+
"list",
|
|
621
|
+
"--head",
|
|
622
|
+
data.branch,
|
|
623
|
+
"--state",
|
|
624
|
+
"all",
|
|
625
|
+
"--json",
|
|
626
|
+
"number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
|
|
627
|
+
],
|
|
628
|
+
{ cwd: join(root, "repos", data.repo) },
|
|
629
|
+
)
|
|
630
|
+
if (listed.exitCode === 0) {
|
|
631
|
+
const matches = parseJson<Record<string, unknown>[]>(
|
|
632
|
+
listed.stdout,
|
|
633
|
+
[],
|
|
634
|
+
).filter(
|
|
635
|
+
(item) =>
|
|
636
|
+
item.headRefName === data.branch &&
|
|
637
|
+
item.baseRefName === data.base &&
|
|
638
|
+
typeof item.url === "string",
|
|
639
|
+
)
|
|
640
|
+
if (matches.length === 1) {
|
|
641
|
+
pr = matches[0]!
|
|
642
|
+
const url = pr.url as string
|
|
643
|
+
if (apply) {
|
|
644
|
+
const recorded = yield* claims.reconcile(
|
|
645
|
+
{
|
|
646
|
+
taskId: record.taskId,
|
|
647
|
+
phaseId: record.phaseId,
|
|
648
|
+
revision,
|
|
649
|
+
pr: url,
|
|
650
|
+
},
|
|
651
|
+
root,
|
|
652
|
+
)
|
|
653
|
+
data = recorded.data
|
|
654
|
+
revision = recorded.revision
|
|
655
|
+
}
|
|
656
|
+
changes.push({
|
|
657
|
+
kind: "record-pr",
|
|
658
|
+
target: record.key,
|
|
659
|
+
message: `Record pull request ${url}`,
|
|
660
|
+
status: apply ? "applied" : "planned",
|
|
661
|
+
})
|
|
662
|
+
} else if (matches.length > 1) {
|
|
663
|
+
unresolved.push({
|
|
664
|
+
kind: "multiple-prs",
|
|
665
|
+
target: record.key,
|
|
666
|
+
message: `Multiple pull requests match '${data.branch}' -> '${data.base}'`,
|
|
667
|
+
action: "Record the authoritative PR URL manually",
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
warnings.push({
|
|
672
|
+
kind: "pr-discovery-unavailable",
|
|
673
|
+
target: record.key,
|
|
674
|
+
message:
|
|
675
|
+
listed.stderr.trim() || "Could not discover pull requests",
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (
|
|
681
|
+
pr.state === "MERGED" &&
|
|
682
|
+
!prConflict &&
|
|
683
|
+
data.status !== "done" &&
|
|
684
|
+
data.status !== "dropped"
|
|
685
|
+
) {
|
|
686
|
+
if (data.claim?.state === "active") {
|
|
687
|
+
unresolved.push({
|
|
688
|
+
kind: "merged-with-active-claim",
|
|
689
|
+
target: record.key,
|
|
690
|
+
message:
|
|
691
|
+
"Pull request is merged while the execution unit remains claimed",
|
|
692
|
+
action: "Finish or release the active claim",
|
|
693
|
+
})
|
|
694
|
+
} else {
|
|
695
|
+
if (apply) {
|
|
696
|
+
const completed = yield* claims.reconcile(
|
|
697
|
+
{
|
|
698
|
+
taskId: record.taskId,
|
|
699
|
+
phaseId: record.phaseId,
|
|
700
|
+
revision,
|
|
701
|
+
status: "done",
|
|
702
|
+
},
|
|
703
|
+
root,
|
|
704
|
+
)
|
|
705
|
+
data = completed.data
|
|
706
|
+
revision = completed.revision
|
|
707
|
+
}
|
|
708
|
+
changes.push({
|
|
709
|
+
kind: "mark-done",
|
|
710
|
+
target: record.key,
|
|
711
|
+
message: "Mark execution unit done from merged pull request",
|
|
712
|
+
status: apply ? "applied" : "planned",
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
executions.push({
|
|
718
|
+
target: record.key,
|
|
719
|
+
status: data.status,
|
|
720
|
+
branch: data.branch,
|
|
721
|
+
base: data.base,
|
|
722
|
+
claim: data.claim ?? null,
|
|
723
|
+
checkouts: checkoutStates,
|
|
724
|
+
pr,
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
root,
|
|
730
|
+
mode: apply ? "apply" : "dry-run",
|
|
731
|
+
changes,
|
|
732
|
+
warnings,
|
|
733
|
+
unresolved,
|
|
734
|
+
executions,
|
|
735
|
+
} satisfies SyncResult
|
|
736
|
+
}),
|
|
737
|
+
}),
|
|
738
|
+
}) {}
|