@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.
@@ -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
+ }) {}