@markjaquith/agency 2.55.0 → 2.56.1
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 +50 -0
- package/package.json +2 -1
- package/src/commands/sync.test.ts +27 -0
- package/src/commands/sync.ts +40 -5
- package/src/services/DoctorService.ts +12 -0
- package/src/services/SyncService.test.ts +115 -0
- package/src/services/SyncService.ts +198 -66
- package/src/services/WorkbaseService.test.ts +24 -0
- package/src/services/WorkbaseService.ts +18 -0
- package/src/services/WorktreeService.test.ts +383 -1
- package/src/services/WorktreeService.ts +232 -4
- package/src/workbase/checkout-command.test.ts +62 -0
- package/src/workbase/checkout-command.ts +66 -0
- package/src/workbase/schemas.test.ts +34 -0
- package/src/workbase/schemas.ts +1 -0
package/README.md
CHANGED
|
@@ -235,6 +235,56 @@ Supplemental read-only repositories remain detached Git worktrees at their
|
|
|
235
235
|
declared refs so they do not acquire writable branches. Jj workbases always use
|
|
236
236
|
jj workspaces and ignore this Git-specific customization.
|
|
237
237
|
|
|
238
|
+
### Post-checkout Commands
|
|
239
|
+
|
|
240
|
+
Each repository declaration may provide a VCS-neutral `postCheckoutCommand` argv
|
|
241
|
+
template for repository-specific setup. Agency invokes it directly, without a
|
|
242
|
+
shell, with the new checkout as its working directory:
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"version": 2,
|
|
247
|
+
"repositories": {
|
|
248
|
+
"frontend": {
|
|
249
|
+
"remote": "git@example.com:team/frontend.git",
|
|
250
|
+
"postCheckoutCommand": ["bun", "install", "--frozen-lockfile"]
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The hook runs for each newly created managed checkout, including writable and
|
|
257
|
+
reference checkouts, after Git worktree or jj workspace creation has completed
|
|
258
|
+
and Agency has validated the checkout. It does not run for a reused checkout or
|
|
259
|
+
for inspection-only commands. A custom `worktreeCreateCommand` completes and is
|
|
260
|
+
validated before this hook runs.
|
|
261
|
+
|
|
262
|
+
Available placeholders and matching environment variables are:
|
|
263
|
+
|
|
264
|
+
| Placeholder | Environment | Value |
|
|
265
|
+
| ------------------ | ------------------------ | ---------------------------------------------- |
|
|
266
|
+
| `{repoAlias}` | `AGENCY_REPO_ALIAS` | Repository alias |
|
|
267
|
+
| `{repositoryPath}` | `AGENCY_REPOSITORY_PATH` | Absolute source repository path under `repos/` |
|
|
268
|
+
| `{checkoutPath}` | `AGENCY_CHECKOUT_PATH` | Absolute managed checkout path |
|
|
269
|
+
| `{checkoutKind}` | `AGENCY_CHECKOUT_KIND` | `writable` or `reference` |
|
|
270
|
+
| `{requestedRef}` | `AGENCY_REQUESTED_REF` | Requested branch, reference, or review commit |
|
|
271
|
+
| `{base}` | `AGENCY_BASE` | Configured execution base |
|
|
272
|
+
| `{vcs}` | `AGENCY_VCS` | `git` or `jj` |
|
|
273
|
+
| `{workbaseRoot}` | `AGENCY_WORKBASE_ROOT` | Absolute workbase root |
|
|
274
|
+
| `{taskId}` | `AGENCY_TASK_ID` | Task ID |
|
|
275
|
+
| `{phaseId}` | `AGENCY_PHASE_ID` | Phase ID |
|
|
276
|
+
|
|
277
|
+
`{base}` and `{phaseId}` and their environment variables are empty strings when
|
|
278
|
+
they do not apply. Dry runs report a planned `post-checkout` operation but never
|
|
279
|
+
execute it. Verbose output identifies the repository and expanded command.
|
|
280
|
+
|
|
281
|
+
Hook success is part of checkout creation. A non-zero exit or failure to start
|
|
282
|
+
rolls back the checkout and any branch created by the same operation; if cleanup
|
|
283
|
+
also fails, Agency reports the exact manual recovery action. A later command
|
|
284
|
+
retries checkout creation and the hook rather than reusing an uninitialized
|
|
285
|
+
checkout. Hook commands should be idempotent so a retry is safe after any
|
|
286
|
+
external effects the failed invocation may have completed.
|
|
287
|
+
|
|
238
288
|
### Agent Runners
|
|
239
289
|
|
|
240
290
|
OpenCode and Claude Code are built-in runner presets. Select either preset or a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.56.1",
|
|
4
4
|
"description": "Manage agentic work across repositories with durable workbases",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"tag": "latest"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
|
+
"benchmark:sync": "bun scripts/benchmark-sync.ts",
|
|
61
62
|
"test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
|
|
62
63
|
"test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
|
|
63
64
|
"format": "oxfmt",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createTempDir,
|
|
10
10
|
runTestEffect,
|
|
11
11
|
} from "../test-utils"
|
|
12
|
+
import type { Progress } from "../utils/progress"
|
|
12
13
|
import { sync } from "./sync"
|
|
13
14
|
|
|
14
15
|
const git = async (args: string[], cwd: string) => {
|
|
@@ -97,4 +98,30 @@ describe("sync command", () => {
|
|
|
97
98
|
],
|
|
98
99
|
})
|
|
99
100
|
})
|
|
101
|
+
|
|
102
|
+
test("reports human-readable progress without polluting JSON output", async () => {
|
|
103
|
+
const updates: string[] = []
|
|
104
|
+
const progress: Progress = {
|
|
105
|
+
start: (message) => updates.push(`start:${message}`),
|
|
106
|
+
succeed: (message) => updates.push(`succeed:${message}`),
|
|
107
|
+
fail: (message) => updates.push(`fail:${message}`),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await captureLogs(() =>
|
|
111
|
+
runTestEffect(sync({ cwd: root, silent: false }, progress)),
|
|
112
|
+
)
|
|
113
|
+
expect(updates).toEqual([
|
|
114
|
+
"start:Validating workbase",
|
|
115
|
+
"start:Inspected 1 repositories",
|
|
116
|
+
"start:Queried pull requests 1/1 (task:example)",
|
|
117
|
+
"start:Reconciled execution units 1/1 (task:example)",
|
|
118
|
+
"succeed:Synchronized 1 execution units",
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
updates.length = 0
|
|
122
|
+
await captureLogs(() =>
|
|
123
|
+
runTestEffect(sync({ cwd: root, dryRun: true, json: true }, progress)),
|
|
124
|
+
)
|
|
125
|
+
expect(updates).toEqual([])
|
|
126
|
+
})
|
|
100
127
|
})
|
package/src/commands/sync.ts
CHANGED
|
@@ -2,19 +2,54 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { SyncService } from "../services/SyncService"
|
|
3
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
4
|
import { createLoggers } from "../utils/effect"
|
|
5
|
+
import { createProgress, type Progress } from "../utils/progress"
|
|
5
6
|
|
|
6
7
|
interface SyncCommandOptions extends BaseCommandOptions {
|
|
7
8
|
readonly dryRun?: boolean
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export const sync = (
|
|
11
|
+
export const sync = (
|
|
12
|
+
options: SyncCommandOptions = {},
|
|
13
|
+
progress: Progress = createProgress({
|
|
14
|
+
silent: options.silent || options.json,
|
|
15
|
+
}),
|
|
16
|
+
) =>
|
|
11
17
|
Effect.gen(function* () {
|
|
12
18
|
const service = yield* SyncService
|
|
13
19
|
const { log } = createLoggers(options)
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const showProgress = !options.silent && !options.json
|
|
21
|
+
if (showProgress) progress.start("Validating workbase")
|
|
22
|
+
const result = yield* service
|
|
23
|
+
.reconcile({
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
apply: options.dryRun !== true,
|
|
26
|
+
onProgress: showProgress
|
|
27
|
+
? ({ stage, current, total, target }) => {
|
|
28
|
+
if (stage === "repositories") {
|
|
29
|
+
progress.start(`Inspected ${total} repositories`)
|
|
30
|
+
} else if (stage === "pull-requests") {
|
|
31
|
+
progress.start(
|
|
32
|
+
`Queried pull requests ${current}/${total}${target ? ` (${target})` : ""}`,
|
|
33
|
+
)
|
|
34
|
+
} else {
|
|
35
|
+
progress.start(
|
|
36
|
+
`Reconciled execution units ${current}/${total}${target ? ` (${target})` : ""}`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
: undefined,
|
|
41
|
+
})
|
|
42
|
+
.pipe(
|
|
43
|
+
Effect.tapError(() =>
|
|
44
|
+
Effect.sync(() => {
|
|
45
|
+
if (showProgress) progress.fail("Workbase sync failed")
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
if (showProgress)
|
|
50
|
+
progress.succeed(
|
|
51
|
+
`Synchronized ${result.executions.length} execution units`,
|
|
52
|
+
)
|
|
18
53
|
if (options.json) return log(JSON.stringify(result, null, 2))
|
|
19
54
|
|
|
20
55
|
for (const action of result.repositories.actions) {
|
|
@@ -173,6 +173,18 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
173
173
|
] as const,
|
|
174
174
|
]
|
|
175
175
|
: []),
|
|
176
|
+
...Object.entries(config.repositories ?? {}).flatMap(
|
|
177
|
+
([alias, repository]) =>
|
|
178
|
+
repository.postCheckoutCommand
|
|
179
|
+
? [
|
|
180
|
+
[
|
|
181
|
+
`integration.repository.${alias}.post-checkout`,
|
|
182
|
+
repository.postCheckoutCommand,
|
|
183
|
+
`Repository '${alias}' post-checkout hook`,
|
|
184
|
+
] as const,
|
|
185
|
+
]
|
|
186
|
+
: [],
|
|
187
|
+
),
|
|
176
188
|
...Object.entries(config.runners ?? {}).map(
|
|
177
189
|
([name, runner]) =>
|
|
178
190
|
[
|
|
@@ -796,4 +796,119 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
796
796
|
),
|
|
797
797
|
).toMatchObject({ valid: true, issues: [] })
|
|
798
798
|
})
|
|
799
|
+
|
|
800
|
+
test("reads each repository workspace inventory once", async () => {
|
|
801
|
+
for (const id of ["first", "second"]) {
|
|
802
|
+
await runTestEffect(
|
|
803
|
+
TaskService.pipe(
|
|
804
|
+
Effect.flatMap((service) =>
|
|
805
|
+
service.create(
|
|
806
|
+
{
|
|
807
|
+
id,
|
|
808
|
+
ticketUrl: null,
|
|
809
|
+
repo: "agency",
|
|
810
|
+
branch: `feat/${id}`,
|
|
811
|
+
base: "main",
|
|
812
|
+
},
|
|
813
|
+
root,
|
|
814
|
+
),
|
|
815
|
+
),
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
await runTestEffect(
|
|
819
|
+
WorktreeService.pipe(
|
|
820
|
+
Effect.flatMap((service) => service.materialize(id, undefined, root)),
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
await runTestEffect(
|
|
824
|
+
TaskService.pipe(
|
|
825
|
+
Effect.flatMap((service) =>
|
|
826
|
+
service.setStatus(id, "done", root, {
|
|
827
|
+
summary: `Completed ${id}`,
|
|
828
|
+
}),
|
|
829
|
+
),
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const callsPath = join(root, "workspace-list-calls")
|
|
835
|
+
const realGit = Bun.which("git")!
|
|
836
|
+
const gitWrapper = join(root, "bin", "git")
|
|
837
|
+
await Bun.write(
|
|
838
|
+
gitWrapper,
|
|
839
|
+
`#!/bin/sh
|
|
840
|
+
case "$*" in
|
|
841
|
+
*"worktree list --porcelain -z"*) printf 'call\\n' >> ${JSON.stringify(callsPath)} ;;
|
|
842
|
+
esac
|
|
843
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
844
|
+
`,
|
|
845
|
+
)
|
|
846
|
+
await chmod(gitWrapper, 0o755)
|
|
847
|
+
|
|
848
|
+
await runTestEffect(
|
|
849
|
+
SyncService.pipe(
|
|
850
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
851
|
+
),
|
|
852
|
+
)
|
|
853
|
+
expect((await Bun.file(callsPath).text()).trim().split("\n")).toHaveLength(
|
|
854
|
+
1,
|
|
855
|
+
)
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
test("queries pull request providers concurrently", async () => {
|
|
859
|
+
for (const id of ["first", "second", "third"]) {
|
|
860
|
+
await runTestEffect(
|
|
861
|
+
TaskService.pipe(
|
|
862
|
+
Effect.flatMap((service) =>
|
|
863
|
+
service.create(
|
|
864
|
+
{
|
|
865
|
+
id,
|
|
866
|
+
ticketUrl: null,
|
|
867
|
+
repo: "agency",
|
|
868
|
+
branch: `feat/${id}`,
|
|
869
|
+
base: "main",
|
|
870
|
+
},
|
|
871
|
+
root,
|
|
872
|
+
),
|
|
873
|
+
),
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const barrier = join(root, "query-barrier")
|
|
879
|
+
await mkdir(barrier)
|
|
880
|
+
await Bun.write(
|
|
881
|
+
join(root, "bin", "gh"),
|
|
882
|
+
`#!/bin/sh
|
|
883
|
+
branch=""
|
|
884
|
+
while [ "$#" -gt 0 ]; do
|
|
885
|
+
if [ "$1" = "--head" ]; then branch="$2"; break; fi
|
|
886
|
+
shift
|
|
887
|
+
done
|
|
888
|
+
id="\${branch##*/}"
|
|
889
|
+
touch ${JSON.stringify(barrier)}/"\${id}"
|
|
890
|
+
attempt=0
|
|
891
|
+
while [ "$attempt" -lt 200 ]; do
|
|
892
|
+
set -- ${JSON.stringify(barrier)}/*
|
|
893
|
+
if [ -e "$1" ] && [ "$#" -ge 3 ]; then printf '[]\\n'; exit 0; fi
|
|
894
|
+
attempt=$((attempt + 1))
|
|
895
|
+
sleep 0.01
|
|
896
|
+
done
|
|
897
|
+
echo "provider queries were serialized" >&2
|
|
898
|
+
exit 9
|
|
899
|
+
`,
|
|
900
|
+
)
|
|
901
|
+
await chmod(join(root, "bin", "gh"), 0o755)
|
|
902
|
+
|
|
903
|
+
const result = await runTestEffect(
|
|
904
|
+
SyncService.pipe(
|
|
905
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
906
|
+
),
|
|
907
|
+
)
|
|
908
|
+
expect(
|
|
909
|
+
result.warnings.filter(
|
|
910
|
+
(warning) => warning.kind === "pr-discovery-unavailable",
|
|
911
|
+
),
|
|
912
|
+
).toEqual([])
|
|
913
|
+
})
|
|
799
914
|
})
|