@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
package/README.md
CHANGED
|
@@ -304,6 +304,25 @@ inspection are opt-in include layers.
|
|
|
304
304
|
`end` record with counts. Combining the metadata with the streamed node and edge
|
|
305
305
|
records reconstructs the same result as `--json`.
|
|
306
306
|
|
|
307
|
+
### Reconciliation
|
|
308
|
+
|
|
309
|
+
`agency sync` compares every execution declaration with local branch and worktree
|
|
310
|
+
registration, writable and reference checkout dirtiness, resolved reference
|
|
311
|
+
commits, claim expiry, and GitHub pull request and merge state. It reports
|
|
312
|
+
structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
313
|
+
default and `--dry-run` modes are observational.
|
|
314
|
+
|
|
315
|
+
`agency sync --apply` performs only these safe transitions:
|
|
316
|
+
|
|
317
|
+
- materialize missing checkouts when no registration, branch, or path conflicts;
|
|
318
|
+
- release an active claim only after its declared expiry has passed;
|
|
319
|
+
- record a single PR whose head and base match the declaration; and
|
|
320
|
+
- mark work done after its authoritative PR is merged and no active claim remains.
|
|
321
|
+
|
|
322
|
+
Apply never modifies dirty checkouts, moves worktrees, switches branches, resets
|
|
323
|
+
reference commits, chooses among multiple PRs, or bypasses active claims. Those
|
|
324
|
+
conditions remain visible in `warnings` or `unresolved` with a suggested action.
|
|
325
|
+
|
|
307
326
|
### Workbase and Repositories
|
|
308
327
|
|
|
309
328
|
```text
|
|
@@ -324,7 +343,7 @@ repository. Alias names are then used by all documents and commands.
|
|
|
324
343
|
|
|
325
344
|
Commands that print Agency-owned results accept `--json`, including initialization,
|
|
326
345
|
integration inspection/sync, repository mutations, entity creation/list/show,
|
|
327
|
-
status, validation, graph export, and PR creation.
|
|
346
|
+
status, validation, graph export, reconciliation, and PR creation.
|
|
328
347
|
|
|
329
348
|
### Epics
|
|
330
349
|
|
package/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { status, help as statusHelp } from "./src/commands/status"
|
|
|
10
10
|
import { validate, help as validateHelp } from "./src/commands/validate"
|
|
11
11
|
import { context, help as contextHelp } from "./src/commands/context"
|
|
12
12
|
import { graph, help as graphHelp } from "./src/commands/graph"
|
|
13
|
+
import { sync, help as syncHelp } from "./src/commands/sync"
|
|
13
14
|
import { repo, help as repoHelp } from "./src/commands/repo"
|
|
14
15
|
import { epic, help as epicHelp } from "./src/commands/epic"
|
|
15
16
|
import { phase, help as phaseHelp } from "./src/commands/phase"
|
|
@@ -33,6 +34,7 @@ import { IntegrationService } from "./src/services/IntegrationService"
|
|
|
33
34
|
import { ContextService } from "./src/services/ContextService"
|
|
34
35
|
import { GraphService } from "./src/services/GraphService"
|
|
35
36
|
import { ClaimService } from "./src/services/ClaimService"
|
|
37
|
+
import { SyncService } from "./src/services/SyncService"
|
|
36
38
|
import {
|
|
37
39
|
claimCommand,
|
|
38
40
|
claimHelp,
|
|
@@ -61,6 +63,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
61
63
|
ContextService.Default,
|
|
62
64
|
GraphService.Default,
|
|
63
65
|
ClaimService.Default,
|
|
66
|
+
SyncService.Default,
|
|
64
67
|
)
|
|
65
68
|
|
|
66
69
|
/**
|
|
@@ -424,6 +427,23 @@ const commands: Record<string, Command> = {
|
|
|
424
427
|
)
|
|
425
428
|
},
|
|
426
429
|
},
|
|
430
|
+
sync: {
|
|
431
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
432
|
+
if (options.help) {
|
|
433
|
+
console.log(syncHelp)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
await runCommand(
|
|
437
|
+
sync({
|
|
438
|
+
apply: options.apply,
|
|
439
|
+
dryRun: options["dry-run"],
|
|
440
|
+
json: options.json,
|
|
441
|
+
silent: options.silent,
|
|
442
|
+
verbose: options.verbose,
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
},
|
|
446
|
+
},
|
|
427
447
|
}
|
|
428
448
|
|
|
429
449
|
function showMainHelp() {
|
|
@@ -450,6 +470,7 @@ Commands:
|
|
|
450
470
|
validate [path] Validate a workbase
|
|
451
471
|
context [target] Return complete target context
|
|
452
472
|
graph Export the complete workbase graph
|
|
473
|
+
sync Reconcile declarations with external state
|
|
453
474
|
|
|
454
475
|
Global Options:
|
|
455
476
|
-h, --help Show help for a command
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -141,6 +141,7 @@ describe("strict CLI parsing", () => {
|
|
|
141
141
|
[["validate", "one", "two"], "agency validate"],
|
|
142
142
|
[["context", "one", "two"], "agency context"],
|
|
143
143
|
[["graph", "extra"], "agency graph"],
|
|
144
|
+
[["sync", "extra"], "agency sync"],
|
|
144
145
|
[
|
|
145
146
|
[
|
|
146
147
|
"claim",
|
|
@@ -191,6 +192,20 @@ describe("strict CLI parsing", () => {
|
|
|
191
192
|
}
|
|
192
193
|
})
|
|
193
194
|
|
|
195
|
+
test("parses reconciliation modes and rejects conflicting modes", () => {
|
|
196
|
+
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
197
|
+
commandName: "sync",
|
|
198
|
+
values: { "dry-run": true, json: true },
|
|
199
|
+
})
|
|
200
|
+
expect(parseCli(["sync", "--apply"])).toMatchObject({
|
|
201
|
+
commandName: "sync",
|
|
202
|
+
values: { apply: true },
|
|
203
|
+
})
|
|
204
|
+
expect(() => parseCli(["sync", "--dry-run", "--apply"])).toThrow(
|
|
205
|
+
"cannot be combined",
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
194
209
|
test("validates revision-guarded claim operations", () => {
|
|
195
210
|
const revision = "0".repeat(64)
|
|
196
211
|
expect(
|
package/src/cli-parser.ts
CHANGED
|
@@ -319,6 +319,21 @@ const commands = {
|
|
|
319
319
|
required: ["session-id", "revision", "outcome"],
|
|
320
320
|
},
|
|
321
321
|
},
|
|
322
|
+
sync: {
|
|
323
|
+
usage: "agency sync [--dry-run | --apply] [--json]",
|
|
324
|
+
options: {
|
|
325
|
+
...outputOptions,
|
|
326
|
+
"dry-run": { type: "boolean" },
|
|
327
|
+
apply: { type: "boolean" },
|
|
328
|
+
},
|
|
329
|
+
command: {
|
|
330
|
+
usage: "agency sync [--dry-run | --apply] [--json]",
|
|
331
|
+
minArgs: 0,
|
|
332
|
+
maxArgs: 0,
|
|
333
|
+
options: ["dry-run", "apply", "json"],
|
|
334
|
+
conflicts: [["dry-run", "apply"]],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
322
337
|
archive: {
|
|
323
338
|
usage: "agency archive <epic|task|phase>",
|
|
324
339
|
options: outputOptions,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { SyncService } from "../services/SyncService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface SyncCommandOptions extends BaseCommandOptions {
|
|
7
|
+
readonly apply?: boolean
|
|
8
|
+
readonly dryRun?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const sync = (options: SyncCommandOptions = {}) =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const service = yield* SyncService
|
|
14
|
+
const { log } = createLoggers(options)
|
|
15
|
+
const result = yield* service.reconcile({
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
apply: options.apply === true,
|
|
18
|
+
})
|
|
19
|
+
log(JSON.stringify(result, null, 2))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const help = `
|
|
23
|
+
Usage: agency sync [--dry-run | --apply] [--json]
|
|
24
|
+
|
|
25
|
+
Compare declared execution state with Git worktrees, branches, references, claims,
|
|
26
|
+
and GitHub pull requests. Dry-run is the default.
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--dry-run Report planned safe transitions without changing state
|
|
30
|
+
--apply Apply safe reconciliation transitions
|
|
31
|
+
--json Output one versioned machine result
|
|
32
|
+
|
|
33
|
+
Apply may materialize unambiguous missing checkouts, release expired claims,
|
|
34
|
+
record a uniquely matched PR, and mark merged work done. Dirty, stale, or
|
|
35
|
+
conflicting checkouts are always left unresolved.
|
|
36
|
+
`
|
package/src/protocol.ts
CHANGED
|
@@ -111,6 +111,11 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
111
111
|
retryable: false,
|
|
112
112
|
remediation: "Correct the workbase graph data or filters and retry.",
|
|
113
113
|
},
|
|
114
|
+
SyncError: {
|
|
115
|
+
code: "SYNC_ERROR",
|
|
116
|
+
retryable: false,
|
|
117
|
+
remediation: "Resolve workbase validation errors before reconciling.",
|
|
118
|
+
},
|
|
114
119
|
ProcessError: { code: "PROCESS_ERROR", retryable: true },
|
|
115
120
|
ProtocolOutputError: {
|
|
116
121
|
code: "PROTOCOL_OUTPUT_ERROR",
|
|
@@ -87,6 +87,23 @@ interface FinishInput extends OwnedClaimInput {
|
|
|
87
87
|
readonly outcome: "done" | "dropped"
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
interface ExpireClaimInput {
|
|
91
|
+
readonly taskId: string
|
|
92
|
+
readonly phaseId?: string
|
|
93
|
+
readonly revision: string
|
|
94
|
+
readonly now?: Date
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ReconcileInput {
|
|
98
|
+
readonly taskId: string
|
|
99
|
+
readonly phaseId?: string
|
|
100
|
+
readonly revision: string
|
|
101
|
+
readonly pr?: string
|
|
102
|
+
readonly status?: "done"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
106
|
+
|
|
90
107
|
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
91
108
|
type ExecutionData = SingleTaskData | PhaseData
|
|
92
109
|
|
|
@@ -286,6 +303,83 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
286
303
|
}
|
|
287
304
|
}),
|
|
288
305
|
|
|
306
|
+
expire: (input: ExpireClaimInput, startPath: string = process.cwd()) =>
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
const service = yield* ClaimService
|
|
309
|
+
const inspected = yield* service.inspect(
|
|
310
|
+
input.taskId,
|
|
311
|
+
input.phaseId,
|
|
312
|
+
startPath,
|
|
313
|
+
)
|
|
314
|
+
return yield* operation(() =>
|
|
315
|
+
updateAtomically(
|
|
316
|
+
inspected.target,
|
|
317
|
+
input.revision,
|
|
318
|
+
(data, now) => {
|
|
319
|
+
if (
|
|
320
|
+
data.claim?.state !== "active" ||
|
|
321
|
+
data.claim.expiresAt === undefined ||
|
|
322
|
+
Date.parse(data.claim.expiresAt) > now.getTime() ||
|
|
323
|
+
(data.status !== "working" && data.status !== "delegated")
|
|
324
|
+
) {
|
|
325
|
+
throw new ClaimError({
|
|
326
|
+
target: inspected.target.label,
|
|
327
|
+
message: `${inspected.target.label} does not have an expired active claim`,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
const claim: ClaimRecord = {
|
|
331
|
+
...data.claim,
|
|
332
|
+
state: "released",
|
|
333
|
+
releasedAt: now.toISOString(),
|
|
334
|
+
}
|
|
335
|
+
return { data: { ...data, status: "open", claim }, claim }
|
|
336
|
+
},
|
|
337
|
+
input.now ?? new Date(),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
}),
|
|
341
|
+
|
|
342
|
+
reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
|
|
343
|
+
Effect.gen(function* () {
|
|
344
|
+
if (input.pr !== undefined && !PR_URL.test(input.pr)) {
|
|
345
|
+
return yield* new ClaimError({
|
|
346
|
+
message: `Invalid GitHub pull request URL: ${input.pr}`,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
const service = yield* ClaimService
|
|
350
|
+
const inspected = yield* service.inspect(
|
|
351
|
+
input.taskId,
|
|
352
|
+
input.phaseId,
|
|
353
|
+
startPath,
|
|
354
|
+
)
|
|
355
|
+
return yield* operation(() =>
|
|
356
|
+
updateAtomically(
|
|
357
|
+
inspected.target,
|
|
358
|
+
input.revision,
|
|
359
|
+
(data) => {
|
|
360
|
+
if (input.status === "done" && data.claim?.state === "active") {
|
|
361
|
+
throw new ClaimConflictError({
|
|
362
|
+
target: inspected.target.label,
|
|
363
|
+
currentRevision: input.revision,
|
|
364
|
+
claim: data.claim,
|
|
365
|
+
message: `${inspected.target.label} has an active claim`,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
data: {
|
|
370
|
+
...data,
|
|
371
|
+
...(input.pr !== undefined ? { pr: input.pr } : {}),
|
|
372
|
+
...(input.status !== undefined
|
|
373
|
+
? { status: input.status }
|
|
374
|
+
: {}),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
new Date(),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
}),
|
|
382
|
+
|
|
289
383
|
claim: (input: ClaimInput, startPath: string = process.cwd()) =>
|
|
290
384
|
Effect.gen(function* () {
|
|
291
385
|
for (const [label, value] of [
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { chmod, mkdir, rm } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { ClaimService } from "./ClaimService"
|
|
7
|
+
import { PullRequestService } from "./PullRequestService"
|
|
8
|
+
import { SyncService } from "./SyncService"
|
|
9
|
+
import { TaskService } from "./TaskService"
|
|
10
|
+
import { WorktreeService } from "./WorktreeService"
|
|
11
|
+
|
|
12
|
+
const git = async (args: string[], cwd?: string) => {
|
|
13
|
+
const process = Bun.spawn(["git", ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
})
|
|
18
|
+
await process.exited
|
|
19
|
+
if (process.exitCode !== 0) {
|
|
20
|
+
throw new Error(await new Response(process.stderr).text())
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("SyncService", () => {
|
|
25
|
+
let root: string
|
|
26
|
+
let originalPath: string | undefined
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
root = await createTempDir()
|
|
30
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
31
|
+
const source = join(root, "source")
|
|
32
|
+
await mkdir(source, { recursive: true })
|
|
33
|
+
await git(["init", "--initial-branch=main"], source)
|
|
34
|
+
await git(["config", "user.email", "test@example.com"], source)
|
|
35
|
+
await git(["config", "user.name", "Test"], source)
|
|
36
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
37
|
+
await git(["add", "README.md"], source)
|
|
38
|
+
await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
|
|
39
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
40
|
+
await git(["clone", "--bare", source, join(root, "repos/agency")])
|
|
41
|
+
await git(["clone", "--bare", source, join(root, "repos/reference")])
|
|
42
|
+
|
|
43
|
+
const bin = join(root, "bin")
|
|
44
|
+
await mkdir(bin)
|
|
45
|
+
const gh = join(bin, "gh")
|
|
46
|
+
await Bun.write(
|
|
47
|
+
gh,
|
|
48
|
+
`#!/bin/sh
|
|
49
|
+
if [ "$2" = "view" ]; then
|
|
50
|
+
cat <<'JSON'
|
|
51
|
+
{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}
|
|
52
|
+
JSON
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
cat <<'JSON'
|
|
56
|
+
[{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}]
|
|
57
|
+
JSON
|
|
58
|
+
`,
|
|
59
|
+
)
|
|
60
|
+
await chmod(gh, 0o755)
|
|
61
|
+
originalPath = process.env.PATH
|
|
62
|
+
process.env.PATH = `${bin}:${originalPath}`
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
if (originalPath === undefined) delete process.env.PATH
|
|
67
|
+
else process.env.PATH = originalPath
|
|
68
|
+
await cleanupTempDir(root)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("observes drift without mutation and applies only safe transitions", async () => {
|
|
72
|
+
await runTestEffect(
|
|
73
|
+
TaskService.pipe(
|
|
74
|
+
Effect.flatMap((service) =>
|
|
75
|
+
service.create(
|
|
76
|
+
{
|
|
77
|
+
id: "example",
|
|
78
|
+
ticketUrl: null,
|
|
79
|
+
repo: "agency",
|
|
80
|
+
repos: [{ repo: "reference", ref: "main" }],
|
|
81
|
+
branch: "feat/example",
|
|
82
|
+
base: "main",
|
|
83
|
+
},
|
|
84
|
+
root,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
const workspace = await runTestEffect(
|
|
90
|
+
WorktreeService.pipe(
|
|
91
|
+
Effect.flatMap((service) =>
|
|
92
|
+
service.materialize("example", undefined, root),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
await git(
|
|
97
|
+
["remote", "set-url", "origin", "git@github.com:example/agency.git"],
|
|
98
|
+
join(root, "repos/agency"),
|
|
99
|
+
)
|
|
100
|
+
const inspected = await runTestEffect(
|
|
101
|
+
ClaimService.pipe(
|
|
102
|
+
Effect.flatMap((service) =>
|
|
103
|
+
service.inspect("example", undefined, root),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
await runTestEffect(
|
|
108
|
+
ClaimService.pipe(
|
|
109
|
+
Effect.flatMap((service) =>
|
|
110
|
+
service.claim(
|
|
111
|
+
{
|
|
112
|
+
taskId: "example",
|
|
113
|
+
claimant: "orchestrator",
|
|
114
|
+
runner: "agent",
|
|
115
|
+
sessionId: "session-1",
|
|
116
|
+
revision: inspected.revision,
|
|
117
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
118
|
+
},
|
|
119
|
+
root,
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
await Bun.write(
|
|
125
|
+
join(workspace.codePath, "reference", "LOCAL.md"),
|
|
126
|
+
"dirty\n",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const taskPath = join(root, "tasks/example/TASK.md")
|
|
130
|
+
const before = await Bun.file(taskPath).text()
|
|
131
|
+
const observed = await runTestEffect(
|
|
132
|
+
SyncService.pipe(
|
|
133
|
+
Effect.flatMap((service) =>
|
|
134
|
+
service.reconcile({ cwd: root, now: new Date("2100-01-02") }),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(observed.mode).toBe("dry-run")
|
|
140
|
+
expect(observed.warnings).toContainEqual(
|
|
141
|
+
expect.objectContaining({
|
|
142
|
+
kind: "dirty-reference",
|
|
143
|
+
target: "task:example",
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
expect(observed.changes.map((change) => change.kind)).toEqual([
|
|
147
|
+
"release-stale-claim",
|
|
148
|
+
"record-pr",
|
|
149
|
+
"mark-done",
|
|
150
|
+
])
|
|
151
|
+
expect(await Bun.file(taskPath).text()).toBe(before)
|
|
152
|
+
|
|
153
|
+
const applied = await runTestEffect(
|
|
154
|
+
SyncService.pipe(
|
|
155
|
+
Effect.flatMap((service) =>
|
|
156
|
+
service.reconcile({
|
|
157
|
+
cwd: root,
|
|
158
|
+
apply: true,
|
|
159
|
+
now: new Date("2100-01-02"),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
expect(applied.changes.map((change) => change.kind)).toEqual([
|
|
165
|
+
"release-stale-claim",
|
|
166
|
+
"record-pr",
|
|
167
|
+
"mark-done",
|
|
168
|
+
])
|
|
169
|
+
expect(applied.changes.every((change) => change.status === "applied")).toBe(
|
|
170
|
+
true,
|
|
171
|
+
)
|
|
172
|
+
const task = await runTestEffect(
|
|
173
|
+
TaskService.pipe(
|
|
174
|
+
Effect.flatMap((service) => service.show("example", root)),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
expect(task.data).toMatchObject({
|
|
178
|
+
status: "done",
|
|
179
|
+
pr: "https://github.com/example/agency/pull/42",
|
|
180
|
+
claim: { state: "released", sessionId: "session-1" },
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
|
|
185
|
+
for (const [id, branch] of [
|
|
186
|
+
["missing", "feat/missing"],
|
|
187
|
+
["conflict", "feat/conflict"],
|
|
188
|
+
] as const) {
|
|
189
|
+
await runTestEffect(
|
|
190
|
+
TaskService.pipe(
|
|
191
|
+
Effect.flatMap((service) =>
|
|
192
|
+
service.create(
|
|
193
|
+
{ id, ticketUrl: null, repo: "agency", branch, base: "main" },
|
|
194
|
+
root,
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const repository = join(root, "repos/agency")
|
|
201
|
+
await git(["branch", "feat/conflict", "main"], repository)
|
|
202
|
+
await git(
|
|
203
|
+
["worktree", "add", join(root, "external-conflict"), "feat/conflict"],
|
|
204
|
+
repository,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const observed = await runTestEffect(
|
|
208
|
+
SyncService.pipe(
|
|
209
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
expect(observed.changes).toContainEqual(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
kind: "materialize-workspace",
|
|
215
|
+
target: "task:missing",
|
|
216
|
+
status: "planned",
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
expect(observed.unresolved).toContainEqual(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
kind: "branch-conflict",
|
|
222
|
+
target: "task:conflict",
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
expect(
|
|
226
|
+
await Bun.file(
|
|
227
|
+
join(root, "tasks/missing/code/agency/README.md"),
|
|
228
|
+
).exists(),
|
|
229
|
+
).toBe(false)
|
|
230
|
+
|
|
231
|
+
const applied = await runTestEffect(
|
|
232
|
+
SyncService.pipe(
|
|
233
|
+
Effect.flatMap((service) =>
|
|
234
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
expect(applied.changes).toContainEqual(
|
|
239
|
+
expect.objectContaining({
|
|
240
|
+
kind: "materialize-workspace",
|
|
241
|
+
target: "task:missing",
|
|
242
|
+
status: "applied",
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
expect(
|
|
246
|
+
applied.executions.find((item) => item.target === "task:missing")
|
|
247
|
+
?.checkouts[0],
|
|
248
|
+
).toMatchObject({ exists: true, registered: true, dirty: false })
|
|
249
|
+
expect(
|
|
250
|
+
await Bun.file(join(root, "tasks/missing/code/agency/README.md")).text(),
|
|
251
|
+
).toBe("example\n")
|
|
252
|
+
expect(
|
|
253
|
+
await Bun.file(
|
|
254
|
+
join(root, "tasks/conflict/code/agency/README.md"),
|
|
255
|
+
).exists(),
|
|
256
|
+
).toBe(false)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test("leaves a missing checkout registration unresolved", async () => {
|
|
260
|
+
await runTestEffect(
|
|
261
|
+
TaskService.pipe(
|
|
262
|
+
Effect.flatMap((service) =>
|
|
263
|
+
service.create(
|
|
264
|
+
{
|
|
265
|
+
id: "stale",
|
|
266
|
+
ticketUrl: null,
|
|
267
|
+
repo: "agency",
|
|
268
|
+
branch: "feat/stale",
|
|
269
|
+
base: "main",
|
|
270
|
+
},
|
|
271
|
+
root,
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
const workspace = await runTestEffect(
|
|
277
|
+
WorktreeService.pipe(
|
|
278
|
+
Effect.flatMap((service) =>
|
|
279
|
+
service.materialize("stale", undefined, root),
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
await rm(workspace.writablePath, { recursive: true, force: true })
|
|
284
|
+
|
|
285
|
+
const observed = await runTestEffect(
|
|
286
|
+
SyncService.pipe(
|
|
287
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
expect(observed.changes).toEqual([])
|
|
291
|
+
expect(observed.unresolved).toContainEqual(
|
|
292
|
+
expect.objectContaining({
|
|
293
|
+
kind: "stale-registration",
|
|
294
|
+
target: "task:stale",
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const applied = await runTestEffect(
|
|
299
|
+
SyncService.pipe(
|
|
300
|
+
Effect.flatMap((service) =>
|
|
301
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
expect(applied.changes).toEqual([])
|
|
306
|
+
expect(
|
|
307
|
+
await Bun.file(join(workspace.writablePath, "README.md")).exists(),
|
|
308
|
+
).toBe(false)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("does not trust a recorded PR from another repository", async () => {
|
|
312
|
+
await runTestEffect(
|
|
313
|
+
TaskService.pipe(
|
|
314
|
+
Effect.flatMap((service) =>
|
|
315
|
+
service.create(
|
|
316
|
+
{
|
|
317
|
+
id: "example",
|
|
318
|
+
ticketUrl: null,
|
|
319
|
+
repo: "agency",
|
|
320
|
+
branch: "feat/example",
|
|
321
|
+
base: "main",
|
|
322
|
+
},
|
|
323
|
+
root,
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
await runTestEffect(
|
|
329
|
+
PullRequestService.pipe(
|
|
330
|
+
Effect.flatMap((service) =>
|
|
331
|
+
service.setUrl(
|
|
332
|
+
"example",
|
|
333
|
+
undefined,
|
|
334
|
+
"https://github.com/other/repository/pull/42",
|
|
335
|
+
root,
|
|
336
|
+
),
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
const applied = await runTestEffect(
|
|
342
|
+
SyncService.pipe(
|
|
343
|
+
Effect.flatMap((service) =>
|
|
344
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
expect(applied.unresolved).toContainEqual(
|
|
349
|
+
expect.objectContaining({
|
|
350
|
+
kind: "pr-repository-conflict",
|
|
351
|
+
target: "task:example",
|
|
352
|
+
}),
|
|
353
|
+
)
|
|
354
|
+
expect(applied.changes.some((change) => change.kind === "mark-done")).toBe(
|
|
355
|
+
false,
|
|
356
|
+
)
|
|
357
|
+
const task = await runTestEffect(
|
|
358
|
+
TaskService.pipe(
|
|
359
|
+
Effect.flatMap((service) => service.show("example", root)),
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
expect(task.data).toMatchObject({ status: "open" })
|
|
363
|
+
})
|
|
364
|
+
})
|