@markjaquith/agency 2.71.9 → 2.71.11
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.71.
|
|
3
|
+
"version": "2.71.11",
|
|
4
4
|
"description": "Manage agentic work across repositories with durable workbases",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -70,8 +70,10 @@
|
|
|
70
70
|
"benchmark:doctor": "bun scripts/benchmark-doctor.ts",
|
|
71
71
|
"benchmark:context": "bun scripts/benchmark-context.ts",
|
|
72
72
|
"benchmark:finish": "bun scripts/benchmark-finish.ts",
|
|
73
|
+
"benchmark:push": "bun scripts/benchmark-push.ts",
|
|
73
74
|
"benchmark:sync": "bun scripts/benchmark-sync.ts",
|
|
74
75
|
"benchmark:task": "bun scripts/benchmark-task.ts",
|
|
76
|
+
"benchmark:validate": "bun scripts/benchmark-validate.ts",
|
|
75
77
|
"benchmark:worktree": "bun scripts/benchmark-worktree.ts",
|
|
76
78
|
"test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
|
|
77
79
|
"test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
|
|
@@ -5,6 +5,7 @@ import { join } from "node:path"
|
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { TaskService } from "./TaskService"
|
|
7
7
|
import { PushService } from "./PushService"
|
|
8
|
+
import { parseGitCommits } from "./push-validation"
|
|
8
9
|
import { WorktreeService } from "./WorktreeService"
|
|
9
10
|
|
|
10
11
|
interface CommandResult {
|
|
@@ -39,6 +40,24 @@ const requireCommand = async (args: readonly string[], cwd?: string) => {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
describe("PushService", () => {
|
|
43
|
+
test("parses and validates batched Git commit metadata", () => {
|
|
44
|
+
const output = [
|
|
45
|
+
"abc123\0Agency Test\0agency@example.com\0First change\0\x1e",
|
|
46
|
+
"def456\0Agency Test\0agency@example.com\0Second change\0\x1e",
|
|
47
|
+
].join("\n")
|
|
48
|
+
const commits = parseGitCommits(output)
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
commits.map(({ commitId, description }) => ({ commitId, description })),
|
|
52
|
+
).toEqual([
|
|
53
|
+
{ commitId: "abc123", description: "First change" },
|
|
54
|
+
{ commitId: "def456", description: "Second change" },
|
|
55
|
+
])
|
|
56
|
+
expect(commits.every((commit) => commit.authorEmail.includes("@"))).toBe(
|
|
57
|
+
true,
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
42
61
|
const roots: string[] = []
|
|
43
62
|
|
|
44
63
|
afterEach(async () => {
|
|
@@ -4,6 +4,7 @@ import { FileSystemService } from "./FileSystemService"
|
|
|
4
4
|
import { PhaseService } from "./PhaseService"
|
|
5
5
|
import { TaskService } from "./TaskService"
|
|
6
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
7
|
+
import { parseGitCommits, type PushCommitMetadata } from "./push-validation"
|
|
7
8
|
|
|
8
9
|
class PushError extends Data.TaggedError("PushError")<{
|
|
9
10
|
readonly message: string
|
|
@@ -15,16 +16,7 @@ interface CommandResult {
|
|
|
15
16
|
readonly stderr: string
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
readonly commitId: string
|
|
20
|
-
readonly changeId?: string
|
|
21
|
-
readonly description: string
|
|
22
|
-
readonly empty: boolean
|
|
23
|
-
readonly authorName: string
|
|
24
|
-
readonly authorEmail: string
|
|
25
|
-
readonly conflict: boolean
|
|
26
|
-
readonly parents: readonly string[]
|
|
27
|
-
}
|
|
19
|
+
type CommitMetadata = PushCommitMetadata
|
|
28
20
|
|
|
29
21
|
interface PushResult {
|
|
30
22
|
readonly vcs: "git" | "jj"
|
|
@@ -105,29 +97,6 @@ const gitAncestor = (
|
|
|
105
97
|
}),
|
|
106
98
|
)
|
|
107
99
|
|
|
108
|
-
const parseGitCommits = (output: string): readonly CommitMetadata[] =>
|
|
109
|
-
output
|
|
110
|
-
.split("\x1e")
|
|
111
|
-
.map((record) => record.replace(/^\n+|\n+$/g, ""))
|
|
112
|
-
.filter(Boolean)
|
|
113
|
-
.map((record) => {
|
|
114
|
-
const [
|
|
115
|
-
commitId = "",
|
|
116
|
-
authorName = "",
|
|
117
|
-
authorEmail = "",
|
|
118
|
-
description = "",
|
|
119
|
-
] = record.split("\0")
|
|
120
|
-
return {
|
|
121
|
-
commitId,
|
|
122
|
-
description,
|
|
123
|
-
empty: false,
|
|
124
|
-
authorName,
|
|
125
|
-
authorEmail,
|
|
126
|
-
conflict: false,
|
|
127
|
-
parents: [],
|
|
128
|
-
}
|
|
129
|
-
})
|
|
130
|
-
|
|
131
100
|
const validateGitCommits = (
|
|
132
101
|
commits: readonly CommitMetadata[],
|
|
133
102
|
base: string,
|
|
@@ -194,11 +163,12 @@ const publishGit = (
|
|
|
194
163
|
["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
|
|
195
164
|
`Failed to fetch remote '${remote}'`,
|
|
196
165
|
)
|
|
197
|
-
const tip = yield*
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
166
|
+
const [tip, baseRevision] = yield* Effect.all(
|
|
167
|
+
[
|
|
168
|
+
gitRevision(fs, checkout, "HEAD"),
|
|
169
|
+
gitRevision(fs, checkout, `refs/remotes/${remote}/${base}`),
|
|
170
|
+
],
|
|
171
|
+
{ concurrency: "unbounded" },
|
|
202
172
|
)
|
|
203
173
|
if (!tip || !baseRevision) {
|
|
204
174
|
return yield* new PushError({
|
|
@@ -239,12 +209,6 @@ const publishGit = (
|
|
|
239
209
|
}
|
|
240
210
|
|
|
241
211
|
onProgress?.("publish")
|
|
242
|
-
yield* git(
|
|
243
|
-
fs,
|
|
244
|
-
checkout,
|
|
245
|
-
["push", remote, `HEAD:refs/heads/${branch}`],
|
|
246
|
-
`Failed to push declared branch '${branch}'`,
|
|
247
|
-
)
|
|
248
212
|
yield* git(
|
|
249
213
|
fs,
|
|
250
214
|
checkout,
|
|
@@ -259,17 +223,13 @@ const publishGit = (
|
|
|
259
223
|
fs,
|
|
260
224
|
checkout,
|
|
261
225
|
[
|
|
262
|
-
"
|
|
226
|
+
"push",
|
|
227
|
+
"-u",
|
|
263
228
|
remote,
|
|
264
|
-
|
|
229
|
+
`HEAD:refs/heads/${branch}`,
|
|
230
|
+
`--force-if-includes`,
|
|
265
231
|
],
|
|
266
|
-
`Failed to
|
|
267
|
-
)
|
|
268
|
-
yield* git(
|
|
269
|
-
fs,
|
|
270
|
-
checkout,
|
|
271
|
-
["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
|
|
272
|
-
`Failed to establish upstream tracking for branch '${branch}'`,
|
|
232
|
+
`Failed to push declared branch '${branch}'`,
|
|
273
233
|
)
|
|
274
234
|
return { tip }
|
|
275
235
|
})
|
|
@@ -403,10 +363,25 @@ const publishJj = (
|
|
|
403
363
|
}
|
|
404
364
|
|
|
405
365
|
const baseBookmark = `${base}@${remote}`
|
|
406
|
-
const baseRevision = yield*
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
366
|
+
const [baseRevision, localBookmark, remoteBookmark] = yield* Effect.all(
|
|
367
|
+
[
|
|
368
|
+
optionalJjRevision(
|
|
369
|
+
fs,
|
|
370
|
+
checkout,
|
|
371
|
+
`remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
|
|
372
|
+
),
|
|
373
|
+
optionalJjRevision(
|
|
374
|
+
fs,
|
|
375
|
+
checkout,
|
|
376
|
+
`bookmarks(exact:"${jjExact(branch)}")`,
|
|
377
|
+
),
|
|
378
|
+
optionalJjRevision(
|
|
379
|
+
fs,
|
|
380
|
+
checkout,
|
|
381
|
+
`remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
|
|
382
|
+
),
|
|
383
|
+
],
|
|
384
|
+
{ concurrency: "unbounded" },
|
|
410
385
|
)
|
|
411
386
|
if (!baseRevision) {
|
|
412
387
|
return yield* new PushError({
|
|
@@ -432,28 +407,24 @@ const publishJj = (
|
|
|
432
407
|
catch: (cause) => cause as PushError,
|
|
433
408
|
})
|
|
434
409
|
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
410
|
+
const [localBookmarkIsAncestor, remoteBookmarkIsAncestor] =
|
|
411
|
+
yield* Effect.all(
|
|
412
|
+
[
|
|
413
|
+
localBookmark
|
|
414
|
+
? jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId)
|
|
415
|
+
: Effect.succeed(true),
|
|
416
|
+
remoteBookmark
|
|
417
|
+
? jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId)
|
|
418
|
+
: Effect.succeed(true),
|
|
419
|
+
],
|
|
420
|
+
{ concurrency: "unbounded" },
|
|
421
|
+
)
|
|
422
|
+
if (!localBookmarkIsAncestor) {
|
|
444
423
|
return yield* new PushError({
|
|
445
424
|
message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
|
|
446
425
|
})
|
|
447
426
|
}
|
|
448
|
-
|
|
449
|
-
fs,
|
|
450
|
-
checkout,
|
|
451
|
-
`remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
|
|
452
|
-
)
|
|
453
|
-
if (
|
|
454
|
-
remoteBookmark &&
|
|
455
|
-
!(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
|
|
456
|
-
) {
|
|
427
|
+
if (!remoteBookmarkIsAncestor) {
|
|
457
428
|
return yield* new PushError({
|
|
458
429
|
message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
|
|
459
430
|
})
|
|
@@ -3,6 +3,7 @@ import { Effect } from "effect"
|
|
|
3
3
|
import { mkdir, realpath, rm, symlink } from "node:fs/promises"
|
|
4
4
|
import { dirname, join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { FileSystemService } from "./FileSystemService"
|
|
6
7
|
import { WorkbaseService } from "./WorkbaseService"
|
|
7
8
|
|
|
8
9
|
const write = async (root: string, path: string, content: string) => {
|
|
@@ -104,6 +105,65 @@ pr: null
|
|
|
104
105
|
expect(report.issues).toEqual([])
|
|
105
106
|
})
|
|
106
107
|
|
|
108
|
+
test("reads configuration and documents once during validation", async () => {
|
|
109
|
+
await write(
|
|
110
|
+
root,
|
|
111
|
+
"agency.json",
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
version: 2,
|
|
114
|
+
repositories: {
|
|
115
|
+
agency: { remote: "https://example.com/agency.git" },
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
await write(
|
|
120
|
+
root,
|
|
121
|
+
"tasks/example/TASK.md",
|
|
122
|
+
`---
|
|
123
|
+
ticketUrl: null
|
|
124
|
+
repo: agency
|
|
125
|
+
branch: task/example
|
|
126
|
+
base: main
|
|
127
|
+
pr: null
|
|
128
|
+
---
|
|
129
|
+
`,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const fs = await Effect.runPromise(
|
|
133
|
+
FileSystemService.pipe(Effect.provide(FileSystemService.Default)),
|
|
134
|
+
)
|
|
135
|
+
const reads: string[] = []
|
|
136
|
+
const exists: string[] = []
|
|
137
|
+
const trackedFs = {
|
|
138
|
+
...fs,
|
|
139
|
+
readFile: (path: string) => {
|
|
140
|
+
reads.push(path)
|
|
141
|
+
return fs.readFile(path)
|
|
142
|
+
},
|
|
143
|
+
exists: (path: string) => {
|
|
144
|
+
exists.push(path)
|
|
145
|
+
return fs.exists(path)
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const report = await Effect.runPromise(
|
|
150
|
+
WorkbaseService.pipe(
|
|
151
|
+
Effect.flatMap((service) => service.validate(root)),
|
|
152
|
+
Effect.provide(WorkbaseService.Default),
|
|
153
|
+
Effect.provideService(FileSystemService, trackedFs),
|
|
154
|
+
) as Effect.Effect<unknown, unknown, never>,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(report).toMatchObject({ valid: true, taskCount: 1 })
|
|
158
|
+
expect(
|
|
159
|
+
reads.filter((path) => path === join(root, "agency.json")),
|
|
160
|
+
).toHaveLength(2)
|
|
161
|
+
expect(
|
|
162
|
+
reads.filter((path) => path === join(root, "tasks/example/TASK.md")),
|
|
163
|
+
).toHaveLength(1)
|
|
164
|
+
expect(exists).not.toContain(join(root, "tasks/example/TASK.md"))
|
|
165
|
+
})
|
|
166
|
+
|
|
107
167
|
test("validates non-PR completion invariants without rejecting legacy done work", async () => {
|
|
108
168
|
await write(
|
|
109
169
|
root,
|
|
@@ -65,6 +65,8 @@ interface DocumentRecord<T> {
|
|
|
65
65
|
readonly data: T
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const validationConcurrency = 32
|
|
69
|
+
|
|
68
70
|
interface ValidationDocuments {
|
|
69
71
|
readonly epics: readonly DocumentRecord<EpicData>[]
|
|
70
72
|
readonly tasks: readonly DocumentRecord<TaskData>[]
|
|
@@ -671,10 +673,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
671
673
|
const service = yield* WorkbaseService
|
|
672
674
|
const fs = yield* FileSystemService
|
|
673
675
|
const root = yield* service.discover(startPath)
|
|
676
|
+
const configPath = join(root, "agency.json")
|
|
677
|
+
const configInput = JSON.parse(
|
|
678
|
+
yield* fs.readFile(configPath),
|
|
679
|
+
) as unknown
|
|
680
|
+
const configResult = decode(WorkbaseConfig, configInput)
|
|
681
|
+
if (!configResult.success) {
|
|
682
|
+
return yield* new WorkbaseConfigError({
|
|
683
|
+
path: configPath,
|
|
684
|
+
message: `Invalid workbase configuration in ${configPath}:\n${configResult.error}`,
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
const config = configResult.value
|
|
674
688
|
const issues: ValidationIssue[] = []
|
|
675
689
|
const epics = new Map<string, DocumentRecord<EpicData>>()
|
|
676
690
|
const tasks = new Map<string, DocumentRecord<TaskData>>()
|
|
677
691
|
const phases = new Map<string, DocumentRecord<PhaseData>>()
|
|
692
|
+
const phaseIdsByTask = new Map<string, Set<string>>()
|
|
678
693
|
|
|
679
694
|
const issue = (path: string, message: string) => {
|
|
680
695
|
issues.push({ path: relative(root, path) || ".", message })
|
|
@@ -691,7 +706,13 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
691
706
|
Effect.catchAll(() => Effect.succeed([])),
|
|
692
707
|
)
|
|
693
708
|
|
|
694
|
-
const aliases = new Set(
|
|
709
|
+
const aliases = new Set(Object.keys(config.repositories ?? {}))
|
|
710
|
+
const reposPath = join(root, "repos")
|
|
711
|
+
if (yield* fs.isDirectory(reposPath)) {
|
|
712
|
+
for (const entry of yield* fs.readDirectory(reposPath)) {
|
|
713
|
+
if (!entry.name.startsWith(".agency-")) aliases.add(entry.name)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
695
716
|
|
|
696
717
|
const readDocument = <S extends Schema.Schema.AnyNoContext>(
|
|
697
718
|
path: string,
|
|
@@ -728,7 +749,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
728
749
|
return data ? { id, path, data } : null
|
|
729
750
|
}),
|
|
730
751
|
),
|
|
731
|
-
{ concurrency:
|
|
752
|
+
{ concurrency: validationConcurrency },
|
|
732
753
|
)
|
|
733
754
|
for (const document of epicDocuments) {
|
|
734
755
|
if (document) epics.set(document.id, document)
|
|
@@ -744,6 +765,9 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
744
765
|
const phaseIds = yield* readDirectories(
|
|
745
766
|
join(taskPath, "phases"),
|
|
746
767
|
)
|
|
768
|
+
if (phaseIds.length > 0) {
|
|
769
|
+
phaseIdsByTask.set(id, new Set(phaseIds))
|
|
770
|
+
}
|
|
747
771
|
const taskPhases = yield* Effect.all(
|
|
748
772
|
phaseIds.map((phaseId) =>
|
|
749
773
|
Effect.gen(function* () {
|
|
@@ -762,7 +786,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
762
786
|
: null
|
|
763
787
|
}),
|
|
764
788
|
),
|
|
765
|
-
{ concurrency:
|
|
789
|
+
{ concurrency: validationConcurrency },
|
|
766
790
|
)
|
|
767
791
|
return {
|
|
768
792
|
id,
|
|
@@ -771,7 +795,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
771
795
|
}
|
|
772
796
|
}),
|
|
773
797
|
),
|
|
774
|
-
{ concurrency:
|
|
798
|
+
{ concurrency: validationConcurrency },
|
|
775
799
|
)
|
|
776
800
|
for (const documents of taskDocuments) {
|
|
777
801
|
if (documents.task) tasks.set(documents.id, documents.task)
|
|
@@ -922,10 +946,8 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
922
946
|
}
|
|
923
947
|
}
|
|
924
948
|
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
.filter((key) => key.startsWith(phasePrefix))
|
|
928
|
-
.map((key) => key.slice(phasePrefix.length))
|
|
949
|
+
const actualPhaseIds =
|
|
950
|
+
phaseIdsByTask.get(task.id) ?? new Set<string>()
|
|
929
951
|
|
|
930
952
|
if ("phases" in task.data) {
|
|
931
953
|
const declaredIds = new Set(
|
|
@@ -952,7 +974,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
952
974
|
for (const cycle of findDependencyCycles(task.data.phases)) {
|
|
953
975
|
issue(task.path, `Phase dependency cycle includes '${cycle}'`)
|
|
954
976
|
}
|
|
955
|
-
} else if (actualPhaseIds.
|
|
977
|
+
} else if (actualPhaseIds.size > 0) {
|
|
956
978
|
issue(
|
|
957
979
|
task.path,
|
|
958
980
|
"Single-phase task cannot contain phase directories",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface PushCommitMetadata {
|
|
2
|
+
readonly commitId: string
|
|
3
|
+
readonly changeId?: string
|
|
4
|
+
readonly description: string
|
|
5
|
+
readonly empty: boolean
|
|
6
|
+
readonly authorName: string
|
|
7
|
+
readonly authorEmail: string
|
|
8
|
+
readonly conflict: boolean
|
|
9
|
+
readonly parents: readonly string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const parseGitCommits = (
|
|
13
|
+
output: string,
|
|
14
|
+
): readonly PushCommitMetadata[] =>
|
|
15
|
+
output
|
|
16
|
+
.split("\x1e")
|
|
17
|
+
.map((record) => record.replace(/^\n+|\n+$/g, ""))
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((record) => {
|
|
20
|
+
const [
|
|
21
|
+
commitId = "",
|
|
22
|
+
authorName = "",
|
|
23
|
+
authorEmail = "",
|
|
24
|
+
description = "",
|
|
25
|
+
] = record.split("\0")
|
|
26
|
+
return {
|
|
27
|
+
commitId,
|
|
28
|
+
description,
|
|
29
|
+
empty: false,
|
|
30
|
+
authorName,
|
|
31
|
+
authorEmail,
|
|
32
|
+
conflict: false,
|
|
33
|
+
parents: [],
|
|
34
|
+
}
|
|
35
|
+
})
|