@markjaquith/agency 2.49.0 → 2.50.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 +22 -1
- package/cli.ts +23 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +141 -38
- package/src/cli-parser.test.ts +62 -0
- package/src/cli-parser.ts +55 -1
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +27 -5
- package/src/commands/work.test.ts +2 -0
- package/src/commands/work.ts +2 -2
- package/src/graph-schema.ts +35 -13
- package/src/protocol.ts +1 -0
- package/src/services/ArchiveService.test.ts +2 -2
- package/src/services/ArchiveService.ts +1 -0
- package/src/services/ClaimService.ts +3 -2
- package/src/services/ContextService.ts +56 -4
- package/src/services/DoctorService.ts +45 -1
- package/src/services/GraphMutationService.ts +5 -0
- package/src/services/GraphService.ts +118 -54
- package/src/services/PhaseService.ts +5 -0
- package/src/services/PullRequestService.test.ts +2 -2
- package/src/services/PullRequestService.ts +23 -3
- package/src/services/ReadinessService.ts +7 -3
- package/src/services/ReviewService.test.ts +472 -0
- package/src/services/ReviewService.ts +404 -0
- package/src/services/SyncService.test.ts +2 -2
- package/src/services/SyncService.ts +110 -6
- package/src/services/TaskService.ts +82 -9
- package/src/services/WorkbaseService.ts +2 -1
- package/src/services/WorktreeService.test.ts +16 -16
- package/src/services/WorktreeService.ts +76 -40
- package/src/test-utils.ts +2 -0
- package/src/work-view.ts +14 -6
- package/src/workbase/schemas.test.ts +57 -0
- package/src/workbase/schemas.ts +56 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { Data, Effect, Layer } from "effect"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { lstat, mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname } from "node:path"
|
|
5
|
+
import { FileSystemService } from "./FileSystemService"
|
|
6
|
+
import { PhaseService } from "./PhaseService"
|
|
7
|
+
import { RepositoryService } from "./RepositoryService"
|
|
8
|
+
import { TaskService } from "./TaskService"
|
|
9
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
10
|
+
import {
|
|
11
|
+
WorktreeService,
|
|
12
|
+
type WorktreeRemovalSnapshot,
|
|
13
|
+
} from "./WorktreeService"
|
|
14
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
15
|
+
import {
|
|
16
|
+
documentWriteStep,
|
|
17
|
+
runLifecycleTransaction,
|
|
18
|
+
type TransactionStep,
|
|
19
|
+
} from "./LifecycleTransaction"
|
|
20
|
+
import { RevisionConflictError } from "../workbase/document-revision"
|
|
21
|
+
import {
|
|
22
|
+
formatMarkdownDocument,
|
|
23
|
+
parseFrontmatter,
|
|
24
|
+
} from "../workbase/frontmatter"
|
|
25
|
+
import type { ReviewRecord, ReviewSource } from "../workbase/schemas"
|
|
26
|
+
|
|
27
|
+
class ReviewError extends Data.TaggedError("ReviewError")<{
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly cause?: unknown
|
|
30
|
+
}> {}
|
|
31
|
+
|
|
32
|
+
const githubRepository = (remote: string) => {
|
|
33
|
+
const match = remote
|
|
34
|
+
.replace(/\.git\/?$/, "")
|
|
35
|
+
.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i)
|
|
36
|
+
return match?.[1]?.toLowerCase() ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pinRef = (taskId: string) =>
|
|
40
|
+
`refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
|
|
41
|
+
|
|
42
|
+
const runGit = async (args: readonly string[]) => {
|
|
43
|
+
const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
44
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
45
|
+
child.exited,
|
|
46
|
+
new Response(child.stdout).text(),
|
|
47
|
+
new Response(child.stderr).text(),
|
|
48
|
+
])
|
|
49
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
50
|
+
return stdout.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const WorktreeLayer = Layer.mergeAll(
|
|
54
|
+
FileSystemService.Default,
|
|
55
|
+
WorkbaseService.Default,
|
|
56
|
+
TaskService.Default,
|
|
57
|
+
PhaseService.Default,
|
|
58
|
+
WorktreeService.Default,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
|
|
62
|
+
Effect.runPromise(
|
|
63
|
+
effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const restoreSnapshots = async (
|
|
67
|
+
snapshots: readonly WorktreeRemovalSnapshot[],
|
|
68
|
+
) => {
|
|
69
|
+
for (const snapshot of snapshots) {
|
|
70
|
+
try {
|
|
71
|
+
await lstat(snapshot.path)
|
|
72
|
+
continue
|
|
73
|
+
} catch {}
|
|
74
|
+
await mkdir(dirname(snapshot.path), { recursive: true })
|
|
75
|
+
await runGit(
|
|
76
|
+
snapshot.branch
|
|
77
|
+
? [
|
|
78
|
+
"git",
|
|
79
|
+
"-C",
|
|
80
|
+
snapshot.repositoryPath,
|
|
81
|
+
"worktree",
|
|
82
|
+
"add",
|
|
83
|
+
snapshot.path,
|
|
84
|
+
snapshot.branch,
|
|
85
|
+
]
|
|
86
|
+
: [
|
|
87
|
+
"git",
|
|
88
|
+
"-C",
|
|
89
|
+
snapshot.repositoryPath,
|
|
90
|
+
"worktree",
|
|
91
|
+
"add",
|
|
92
|
+
"--detach",
|
|
93
|
+
snapshot.path,
|
|
94
|
+
snapshot.head,
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const normalizeBranch = (input: string, repositoryPath: string) =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const fs = yield* FileSystemService
|
|
103
|
+
if (
|
|
104
|
+
input.startsWith("refs/") &&
|
|
105
|
+
!input.startsWith("refs/heads/") &&
|
|
106
|
+
!input.startsWith("refs/remotes/origin/")
|
|
107
|
+
) {
|
|
108
|
+
return yield* new ReviewError({
|
|
109
|
+
message: `Invalid review branch '${input}'`,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
const name = input
|
|
113
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
114
|
+
.replace(/^origin\//, "")
|
|
115
|
+
.replace(/^refs\/heads\//, "")
|
|
116
|
+
if (
|
|
117
|
+
!name ||
|
|
118
|
+
name === "HEAD" ||
|
|
119
|
+
name.startsWith("-") ||
|
|
120
|
+
/[\s:*?\[\\^~]/.test(name) ||
|
|
121
|
+
name.includes("..") ||
|
|
122
|
+
name.includes("@{")
|
|
123
|
+
) {
|
|
124
|
+
return yield* new ReviewError({
|
|
125
|
+
message: `Invalid review branch '${input}'`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
const checked = yield* fs.runCommand(
|
|
129
|
+
["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
|
|
130
|
+
{ captureOutput: true },
|
|
131
|
+
)
|
|
132
|
+
if (checked.exitCode !== 0) {
|
|
133
|
+
return yield* new ReviewError({
|
|
134
|
+
message: `Invalid review branch '${input}'`,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return `refs/heads/${name}`
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const fetchCommit = (repoPath: string, sourceRef: string) =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const fs = yield* FileSystemService
|
|
143
|
+
const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
|
|
144
|
+
const fetched = yield* fs.runCommand(
|
|
145
|
+
[
|
|
146
|
+
"git",
|
|
147
|
+
"-C",
|
|
148
|
+
repoPath,
|
|
149
|
+
"fetch",
|
|
150
|
+
"--no-tags",
|
|
151
|
+
"origin",
|
|
152
|
+
`+${sourceRef}:${temporaryRef}`,
|
|
153
|
+
],
|
|
154
|
+
{ captureOutput: true },
|
|
155
|
+
)
|
|
156
|
+
if (fetched.exitCode !== 0) {
|
|
157
|
+
const cleanup = yield* fs.runCommand(
|
|
158
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
159
|
+
{ captureOutput: true },
|
|
160
|
+
)
|
|
161
|
+
return yield* new ReviewError({
|
|
162
|
+
message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
const resolved = yield* fs.runCommand(
|
|
166
|
+
[
|
|
167
|
+
"git",
|
|
168
|
+
"-C",
|
|
169
|
+
repoPath,
|
|
170
|
+
"rev-parse",
|
|
171
|
+
"--verify",
|
|
172
|
+
`${temporaryRef}^{commit}`,
|
|
173
|
+
],
|
|
174
|
+
{ captureOutput: true },
|
|
175
|
+
)
|
|
176
|
+
const cleanup = yield* fs.runCommand(
|
|
177
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
178
|
+
{ captureOutput: true },
|
|
179
|
+
)
|
|
180
|
+
if (cleanup.exitCode !== 0) {
|
|
181
|
+
return yield* new ReviewError({
|
|
182
|
+
message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
const commit = resolved.stdout.trim()
|
|
186
|
+
if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
|
|
187
|
+
return yield* new ReviewError({
|
|
188
|
+
message: `Review source '${sourceRef}' did not resolve to a commit`,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return commit
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
export class ReviewService extends Effect.Service<ReviewService>()(
|
|
195
|
+
"ReviewService",
|
|
196
|
+
{
|
|
197
|
+
sync: () => ({
|
|
198
|
+
resolve: (
|
|
199
|
+
repo: string,
|
|
200
|
+
input: { readonly pullRequest?: string; readonly ref?: string },
|
|
201
|
+
startPath: string = process.cwd(),
|
|
202
|
+
) =>
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
const repositories = yield* RepositoryService
|
|
205
|
+
const repository = yield* repositories.show(repo, startPath)
|
|
206
|
+
if (!repository.remote || repository.states.includes("missing")) {
|
|
207
|
+
return yield* new ReviewError({
|
|
208
|
+
message: `Repository alias '${repo}' must be materialized with an origin remote`,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
let source: ReviewSource
|
|
212
|
+
let sourceRef: string
|
|
213
|
+
if (input.pullRequest) {
|
|
214
|
+
const urlMatch = input.pullRequest.match(
|
|
215
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/i,
|
|
216
|
+
)
|
|
217
|
+
const identifier =
|
|
218
|
+
urlMatch?.[2] ??
|
|
219
|
+
(/^\d+$/.test(input.pullRequest) ? input.pullRequest : null)
|
|
220
|
+
if (!identifier) {
|
|
221
|
+
return yield* new ReviewError({
|
|
222
|
+
message: `Invalid GitHub pull request '${input.pullRequest}'`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
const originRepository = githubRepository(repository.remote)
|
|
226
|
+
if (!originRepository) {
|
|
227
|
+
return yield* new ReviewError({
|
|
228
|
+
message: `Repository alias '${repo}' does not use a GitHub origin`,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
if (urlMatch && urlMatch[1]!.toLowerCase() !== originRepository) {
|
|
232
|
+
return yield* new ReviewError({
|
|
233
|
+
message: `Pull request repository '${urlMatch[1]}' does not match alias '${repo}' origin '${originRepository}'`,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
sourceRef = `refs/pull/${identifier}/head`
|
|
237
|
+
source = {
|
|
238
|
+
kind: "pull-request",
|
|
239
|
+
provider: "github",
|
|
240
|
+
repository: originRepository,
|
|
241
|
+
identifier,
|
|
242
|
+
url: `https://github.com/${originRepository}/pull/${identifier}`,
|
|
243
|
+
fetchRef: sourceRef,
|
|
244
|
+
}
|
|
245
|
+
} else if (input.ref) {
|
|
246
|
+
sourceRef = yield* normalizeBranch(input.ref, repository.path)
|
|
247
|
+
source = { kind: "branch", ref: sourceRef }
|
|
248
|
+
} else {
|
|
249
|
+
return yield* new ReviewError({
|
|
250
|
+
message: "Exactly one review source is required",
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
const commit = yield* fetchCommit(repository.path, sourceRef)
|
|
254
|
+
return {
|
|
255
|
+
repo,
|
|
256
|
+
source,
|
|
257
|
+
commit,
|
|
258
|
+
refreshedAt: new Date().toISOString(),
|
|
259
|
+
} satisfies ReviewRecord
|
|
260
|
+
}),
|
|
261
|
+
|
|
262
|
+
refresh: (
|
|
263
|
+
taskId: string,
|
|
264
|
+
startPath: string = process.cwd(),
|
|
265
|
+
ifRevision?: string,
|
|
266
|
+
) =>
|
|
267
|
+
Effect.gen(function* () {
|
|
268
|
+
const workbase = yield* WorkbaseService
|
|
269
|
+
const tasks = yield* TaskService
|
|
270
|
+
const worktrees = yield* WorktreeService
|
|
271
|
+
const service = yield* ReviewService
|
|
272
|
+
const repositories = yield* RepositoryService
|
|
273
|
+
const root = yield* workbase.discover(startPath)
|
|
274
|
+
return yield* withWorktreeLocks(
|
|
275
|
+
root,
|
|
276
|
+
[{ taskId }],
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
const task = yield* tasks.show(taskId, root)
|
|
279
|
+
if (!("review" in task.data)) {
|
|
280
|
+
return yield* new ReviewError({
|
|
281
|
+
message: `Task '${taskId}' is not a review task`,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
const previousReview = task.data.review
|
|
285
|
+
if (task.data.claim?.state === "active") {
|
|
286
|
+
return yield* new ReviewError({
|
|
287
|
+
message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (ifRevision && task.revision !== ifRevision) {
|
|
291
|
+
return yield* new RevisionConflictError({
|
|
292
|
+
path: task.path,
|
|
293
|
+
target: `task '${taskId}'`,
|
|
294
|
+
expectedRevision: ifRevision,
|
|
295
|
+
currentRevision: task.revision,
|
|
296
|
+
message: `Revision conflict for task '${taskId}'`,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const inspection = yield* worktrees.inspect(
|
|
300
|
+
taskId,
|
|
301
|
+
undefined,
|
|
302
|
+
root,
|
|
303
|
+
)
|
|
304
|
+
if (
|
|
305
|
+
inspection.conflicts.length ||
|
|
306
|
+
inspection.checkouts.some((checkout) => checkout.dirty)
|
|
307
|
+
) {
|
|
308
|
+
return yield* new ReviewError({
|
|
309
|
+
message: `Cannot refresh review task '${taskId}'; its checkout is dirty or structurally unexpected`,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
const latest = yield* service.resolve(
|
|
313
|
+
task.data.review.repo,
|
|
314
|
+
task.data.review.source.kind === "pull-request"
|
|
315
|
+
? { pullRequest: task.data.review.source.url }
|
|
316
|
+
: { ref: task.data.review.source.ref },
|
|
317
|
+
root,
|
|
318
|
+
)
|
|
319
|
+
const parsed = yield* parseFrontmatter(task.content, task.path)
|
|
320
|
+
const content = formatMarkdownDocument(
|
|
321
|
+
{ ...task.data, review: latest },
|
|
322
|
+
parsed.body,
|
|
323
|
+
)
|
|
324
|
+
const repository = yield* repositories.show(
|
|
325
|
+
task.data.review.repo,
|
|
326
|
+
root,
|
|
327
|
+
)
|
|
328
|
+
const hadCheckout = inspection.checkouts.some(
|
|
329
|
+
(checkout) => checkout.exists || checkout.registered,
|
|
330
|
+
)
|
|
331
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
332
|
+
const steps: TransactionStep[] = []
|
|
333
|
+
if (hadCheckout) {
|
|
334
|
+
steps.push({
|
|
335
|
+
label: `remove review checkout for ${taskId}`,
|
|
336
|
+
apply: () =>
|
|
337
|
+
runWorktreeEffect(
|
|
338
|
+
worktrees.remove(taskId, undefined, root, {
|
|
339
|
+
snapshots,
|
|
340
|
+
lockHeld: true,
|
|
341
|
+
}),
|
|
342
|
+
).then(() => undefined),
|
|
343
|
+
rollback: () => restoreSnapshots(snapshots),
|
|
344
|
+
manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
steps.push(
|
|
348
|
+
documentWriteStep(root, [{ path: task.path, content }]),
|
|
349
|
+
)
|
|
350
|
+
steps.push({
|
|
351
|
+
label: `advance review pin for ${taskId}`,
|
|
352
|
+
apply: () =>
|
|
353
|
+
runGit([
|
|
354
|
+
"git",
|
|
355
|
+
"-C",
|
|
356
|
+
repository.path,
|
|
357
|
+
"update-ref",
|
|
358
|
+
pinRef(taskId),
|
|
359
|
+
latest.commit,
|
|
360
|
+
previousReview.commit,
|
|
361
|
+
]).then(() => undefined),
|
|
362
|
+
rollback: () =>
|
|
363
|
+
runGit([
|
|
364
|
+
"git",
|
|
365
|
+
"-C",
|
|
366
|
+
repository.path,
|
|
367
|
+
"update-ref",
|
|
368
|
+
pinRef(taskId),
|
|
369
|
+
previousReview.commit,
|
|
370
|
+
latest.commit,
|
|
371
|
+
]).then(() => undefined),
|
|
372
|
+
manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
|
|
373
|
+
})
|
|
374
|
+
if (hadCheckout) {
|
|
375
|
+
steps.push({
|
|
376
|
+
label: `create refreshed review checkout for ${taskId}`,
|
|
377
|
+
apply: () =>
|
|
378
|
+
runWorktreeEffect(
|
|
379
|
+
worktrees.materialize(taskId, undefined, root, {
|
|
380
|
+
lockHeld: true,
|
|
381
|
+
}),
|
|
382
|
+
).then(() => undefined),
|
|
383
|
+
manualRecovery: `Run agency work prepare for review task '${taskId}'`,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
yield* runLifecycleTransaction({
|
|
387
|
+
root,
|
|
388
|
+
preconditions: [{ path: task.path, revision: task.revision }],
|
|
389
|
+
steps,
|
|
390
|
+
})
|
|
391
|
+
return {
|
|
392
|
+
taskId,
|
|
393
|
+
previousCommit: task.data.review.commit,
|
|
394
|
+
commit: latest.commit,
|
|
395
|
+
changed: latest.commit !== task.data.review.commit,
|
|
396
|
+
refreshedAt: latest.refreshedAt,
|
|
397
|
+
revision: (yield* tasks.show(taskId, root)).revision,
|
|
398
|
+
}
|
|
399
|
+
}),
|
|
400
|
+
)
|
|
401
|
+
}),
|
|
402
|
+
}),
|
|
403
|
+
},
|
|
404
|
+
) {}
|
|
@@ -583,7 +583,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
583
583
|
),
|
|
584
584
|
),
|
|
585
585
|
)
|
|
586
|
-
await rm(workspace.writablePath
|
|
586
|
+
await rm(workspace.writablePath!, { recursive: true, force: true })
|
|
587
587
|
|
|
588
588
|
const observed = await runTestEffect(
|
|
589
589
|
SyncService.pipe(
|
|
@@ -607,7 +607,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
607
607
|
)
|
|
608
608
|
expect(applied.changes).toEqual([])
|
|
609
609
|
expect(
|
|
610
|
-
await Bun.file(join(workspace.writablePath
|
|
610
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
611
611
|
).toBe(false)
|
|
612
612
|
})
|
|
613
613
|
|
|
@@ -83,11 +83,16 @@ interface CheckoutState {
|
|
|
83
83
|
interface ExecutionSyncState {
|
|
84
84
|
readonly target: string
|
|
85
85
|
readonly status: WorkStatus
|
|
86
|
-
readonly branch: string
|
|
87
|
-
readonly base: string
|
|
86
|
+
readonly branch: string | null
|
|
87
|
+
readonly base: string | null
|
|
88
88
|
readonly claim: ClaimRecord | null
|
|
89
89
|
readonly checkouts: readonly CheckoutState[]
|
|
90
90
|
readonly pr: Record<string, unknown>
|
|
91
|
+
readonly review?: {
|
|
92
|
+
readonly pinnedCommit: string
|
|
93
|
+
readonly sourceCommit: string | null
|
|
94
|
+
readonly sourceAvailable: boolean
|
|
95
|
+
}
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
interface SyncResult {
|
|
@@ -219,7 +224,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
219
224
|
data: phase.data,
|
|
220
225
|
})
|
|
221
226
|
}
|
|
222
|
-
} else {
|
|
227
|
+
} else if (!("review" in task.data)) {
|
|
223
228
|
records.push({
|
|
224
229
|
key: `task:${task.id}`,
|
|
225
230
|
taskId: task.id,
|
|
@@ -551,7 +556,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
551
556
|
},
|
|
552
557
|
root,
|
|
553
558
|
)
|
|
554
|
-
data = expired.data
|
|
559
|
+
data = expired.data as ExecutionData
|
|
555
560
|
revision = expired.revision
|
|
556
561
|
} else {
|
|
557
562
|
const claim: ClaimRecord = {
|
|
@@ -787,7 +792,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
787
792
|
},
|
|
788
793
|
root,
|
|
789
794
|
)
|
|
790
|
-
data = recorded.data
|
|
795
|
+
data = recorded.data as ExecutionData
|
|
791
796
|
revision = recorded.revision
|
|
792
797
|
}
|
|
793
798
|
changes.push({
|
|
@@ -823,7 +828,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
823
828
|
},
|
|
824
829
|
root,
|
|
825
830
|
)
|
|
826
|
-
data = completed.data
|
|
831
|
+
data = completed.data as ExecutionData
|
|
827
832
|
revision = completed.revision
|
|
828
833
|
}
|
|
829
834
|
changes.push({
|
|
@@ -846,6 +851,105 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
846
851
|
})
|
|
847
852
|
}
|
|
848
853
|
|
|
854
|
+
for (const task of (yield* tasks.list(root)).filter(
|
|
855
|
+
(task) => "review" in task.data,
|
|
856
|
+
)) {
|
|
857
|
+
if (!("review" in task.data)) continue
|
|
858
|
+
let data = task.data
|
|
859
|
+
let revision = task.revision
|
|
860
|
+
if (
|
|
861
|
+
isExpired(data.claim, now) &&
|
|
862
|
+
(data.status === "working" || data.status === "delegated")
|
|
863
|
+
) {
|
|
864
|
+
if (apply) {
|
|
865
|
+
const expired = yield* claims.expire(
|
|
866
|
+
{ taskId: task.id, revision, now },
|
|
867
|
+
root,
|
|
868
|
+
)
|
|
869
|
+
if ("review" in expired.data) data = expired.data
|
|
870
|
+
revision = expired.revision
|
|
871
|
+
}
|
|
872
|
+
changes.push({
|
|
873
|
+
kind: "release-stale-claim",
|
|
874
|
+
target: `task:${task.id}`,
|
|
875
|
+
message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
|
|
876
|
+
status: apply ? "applied" : "planned",
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
const inspection = yield* worktrees.inspect(task.id, undefined, root)
|
|
880
|
+
for (const conflict of inspection.conflicts) {
|
|
881
|
+
unresolved.push({
|
|
882
|
+
kind: conflict.kind,
|
|
883
|
+
target: `task:${task.id}`,
|
|
884
|
+
message: conflict.message,
|
|
885
|
+
action: "Repair or remove the review checkout explicitly",
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
const checkout = inspection.checkouts[0]
|
|
889
|
+
if (
|
|
890
|
+
!checkout?.exists &&
|
|
891
|
+
inspection.conflicts.length === 0 &&
|
|
892
|
+
(data.status === "working" || data.status === "delegated")
|
|
893
|
+
) {
|
|
894
|
+
if (apply) yield* worktrees.materialize(task.id, undefined, root)
|
|
895
|
+
changes.push({
|
|
896
|
+
kind: "materialize-workspace",
|
|
897
|
+
target: `task:${task.id}`,
|
|
898
|
+
message: `Materialize pinned review checkout under ${inspection.codePath}`,
|
|
899
|
+
status: apply ? "applied" : "planned",
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
const repositoryPath = join(root, "repos", data.review.repo)
|
|
903
|
+
const source = yield* runExternal([
|
|
904
|
+
"git",
|
|
905
|
+
"-C",
|
|
906
|
+
repositoryPath,
|
|
907
|
+
"ls-remote",
|
|
908
|
+
"origin",
|
|
909
|
+
data.review.source.kind === "pull-request"
|
|
910
|
+
? data.review.source.fetchRef
|
|
911
|
+
: originRef(data.review.source.ref),
|
|
912
|
+
])
|
|
913
|
+
const sourceCommit = source.stdout.trim().split(/\s+/)[0] || null
|
|
914
|
+
if (!sourceCommit) {
|
|
915
|
+
warnings.push({
|
|
916
|
+
kind: "review-source-unavailable",
|
|
917
|
+
target: `task:${task.id}`,
|
|
918
|
+
message:
|
|
919
|
+
"Review source is unavailable; the pinned commit is unchanged",
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
executions.push({
|
|
923
|
+
target: `task:${task.id}`,
|
|
924
|
+
status: data.status,
|
|
925
|
+
branch: null,
|
|
926
|
+
base: null,
|
|
927
|
+
claim: data.claim ?? null,
|
|
928
|
+
checkouts: checkout
|
|
929
|
+
? [
|
|
930
|
+
{
|
|
931
|
+
repo: checkout.repo,
|
|
932
|
+
kind: "reference",
|
|
933
|
+
path: checkout.path,
|
|
934
|
+
requestedRef: data.review.commit,
|
|
935
|
+
resolvedCommit: checkout.expectedCommit,
|
|
936
|
+
registered: checkout.registered,
|
|
937
|
+
exists: checkout.exists,
|
|
938
|
+
head: checkout.actualCommit,
|
|
939
|
+
branch: checkout.actualBranch,
|
|
940
|
+
dirty: checkout.dirty,
|
|
941
|
+
},
|
|
942
|
+
]
|
|
943
|
+
: [],
|
|
944
|
+
pr: { url: null, state: "none" },
|
|
945
|
+
review: {
|
|
946
|
+
pinnedCommit: data.review.commit,
|
|
947
|
+
sourceCommit,
|
|
948
|
+
sourceAvailable: sourceCommit !== null,
|
|
949
|
+
},
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
849
953
|
return {
|
|
850
954
|
root,
|
|
851
955
|
mode: apply ? "apply" : "dry-run",
|