@markjaquith/agency 2.28.1 → 2.30.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 +63 -12
- package/cli.ts +15 -0
- package/fixtures/protocol/skill-setup-commands.json +18 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +25 -15
- package/skills/agency/references/commands.md +34 -17
- package/skills/agency/references/contracts.md +46 -19
- package/skills/agency/references/recipes.md +50 -12
- package/src/cli-parser.test.ts +9 -0
- package/src/cli-parser.ts +20 -3
- package/src/cli.test.ts +205 -3
- package/src/commands/pr.test.ts +20 -1
- package/src/commands/repo.test.ts +66 -1
- package/src/commands/repo.ts +35 -8
- package/src/commands/status.test.ts +22 -0
- package/src/commands/status.ts +1 -0
- package/src/commands/sync.ts +5 -3
- package/src/commands/workbase.ts +2 -1
- package/src/graph-schema.test.ts +52 -4
- package/src/protocol.test.ts +41 -8
- package/src/readiness.test.ts +75 -17
- package/src/services/DoctorService.ts +22 -13
- package/src/services/EpicService.ts +1 -1
- package/src/services/GraphMutationService.ts +3 -3
- package/src/services/GraphService.ts +13 -4
- package/src/services/PhaseService.ts +1 -1
- package/src/services/ReadinessService.test.ts +47 -0
- package/src/services/RepositoryService.test.ts +299 -5
- package/src/services/RepositoryService.ts +725 -98
- package/src/services/SyncService.test.ts +36 -0
- package/src/services/SyncService.ts +20 -1
- package/src/services/TaskService.ts +1 -1
- package/src/services/WorkbaseService.test.ts +32 -0
- package/src/services/WorkbaseService.ts +22 -14
- package/src/services/WorktreeLock.test.ts +122 -0
- package/src/services/WorktreeService.test.ts +17 -2
- package/src/services/WorktreeService.ts +3 -3
- package/src/utils/process.test.ts +4 -3
- package/src/workbase/AGENTS.md +5 -0
- package/src/workbase/dependency-graph.test.ts +50 -0
- package/src/workbase/schemas.test.ts +52 -0
- package/src/workbase/schemas.ts +19 -0
|
@@ -68,6 +68,42 @@ JSON
|
|
|
68
68
|
await cleanupTempDir(root)
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
test("validates before applying repository setup", async () => {
|
|
72
|
+
await rm(join(root, "repos/agency"), { recursive: true, force: true })
|
|
73
|
+
await Bun.write(
|
|
74
|
+
join(root, "agency.json"),
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
version: 2,
|
|
77
|
+
repositories: {
|
|
78
|
+
agency: { remote: "https://example.com/agency.git" },
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
await mkdir(join(root, "tasks/invalid"), { recursive: true })
|
|
83
|
+
await Bun.write(
|
|
84
|
+
join(root, "tasks/invalid/TASK.md"),
|
|
85
|
+
`---
|
|
86
|
+
ticketUrl: null
|
|
87
|
+
repo: unknown
|
|
88
|
+
branch: task/invalid
|
|
89
|
+
base: main
|
|
90
|
+
pr: null
|
|
91
|
+
---
|
|
92
|
+
`,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
runTestEffect(
|
|
97
|
+
SyncService.pipe(
|
|
98
|
+
Effect.flatMap((service) =>
|
|
99
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
).rejects.toThrow("Unknown repository alias 'unknown'")
|
|
104
|
+
expect(await Bun.file(join(root, "repos/agency")).exists()).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
71
107
|
test("observes drift without mutation and applies only safe transitions", async () => {
|
|
72
108
|
await runTestEffect(
|
|
73
109
|
TaskService.pipe(
|
|
@@ -21,6 +21,10 @@ import { PhaseService } from "./PhaseService"
|
|
|
21
21
|
import { TaskService } from "./TaskService"
|
|
22
22
|
import { WorkbaseService } from "./WorkbaseService"
|
|
23
23
|
import { WorktreeService } from "./WorktreeService"
|
|
24
|
+
import {
|
|
25
|
+
RepositoryService,
|
|
26
|
+
type RepositorySetupResult,
|
|
27
|
+
} from "./RepositoryService"
|
|
24
28
|
|
|
25
29
|
class SyncError extends Data.TaggedError("SyncError")<{
|
|
26
30
|
readonly message: string
|
|
@@ -93,6 +97,7 @@ interface SyncResult {
|
|
|
93
97
|
readonly warnings: readonly SyncNotice[]
|
|
94
98
|
readonly unresolved: readonly SyncNotice[]
|
|
95
99
|
readonly executions: readonly ExecutionSyncState[]
|
|
100
|
+
readonly repositories: RepositorySetupResult
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
const parseWorktrees = (output: string): RegisteredWorktree[] => {
|
|
@@ -150,6 +155,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
150
155
|
const phases = yield* PhaseService
|
|
151
156
|
const worktrees = yield* WorktreeService
|
|
152
157
|
const claims = yield* ClaimService
|
|
158
|
+
const repositories = yield* RepositoryService
|
|
153
159
|
const { root, config } = yield* workbase.loadConfig(options.cwd)
|
|
154
160
|
const validation = yield* workbase.validate(root)
|
|
155
161
|
if (!validation.valid) {
|
|
@@ -159,12 +165,24 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
159
165
|
.join("\n"),
|
|
160
166
|
})
|
|
161
167
|
}
|
|
168
|
+
const repositorySetup = yield* repositories.setup({
|
|
169
|
+
cwd: root,
|
|
170
|
+
apply: options.apply === true,
|
|
171
|
+
})
|
|
162
172
|
|
|
163
173
|
const apply = options.apply === true
|
|
164
174
|
const now = options.now ?? new Date()
|
|
165
175
|
const changes: SyncChange[] = []
|
|
166
176
|
const warnings: SyncNotice[] = []
|
|
167
177
|
const unresolved: SyncNotice[] = []
|
|
178
|
+
for (const issue of repositorySetup.unresolved) {
|
|
179
|
+
unresolved.push({
|
|
180
|
+
kind: `repository-${issue.state}`,
|
|
181
|
+
target: `repository:${issue.alias}`,
|
|
182
|
+
message: issue.message,
|
|
183
|
+
action: issue.action,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
168
186
|
const executions: ExecutionSyncState[] = []
|
|
169
187
|
const runExternal = (
|
|
170
188
|
args: readonly string[],
|
|
@@ -240,7 +258,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
240
258
|
kind: "missing-repository",
|
|
241
259
|
target: record.key,
|
|
242
260
|
message: `Repository alias '${checkout.repo}' is missing`,
|
|
243
|
-
action: "
|
|
261
|
+
action: "Run 'agency repo setup --apply' or relink the alias",
|
|
244
262
|
})
|
|
245
263
|
workspaceConflict = true
|
|
246
264
|
continue
|
|
@@ -822,6 +840,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
822
840
|
warnings,
|
|
823
841
|
unresolved,
|
|
824
842
|
executions,
|
|
843
|
+
repositories: repositorySetup,
|
|
825
844
|
} satisfies SyncResult
|
|
826
845
|
}),
|
|
827
846
|
}),
|
|
@@ -140,7 +140,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
140
140
|
})
|
|
141
141
|
}
|
|
142
142
|
for (const alias of referencedRepos) {
|
|
143
|
-
if (!(yield*
|
|
143
|
+
if (!(yield* workbase.hasRepositoryAlias(alias, root))) {
|
|
144
144
|
return yield* new TaskError({
|
|
145
145
|
message: `Unknown repository alias '${alias}'`,
|
|
146
146
|
})
|
|
@@ -41,6 +41,38 @@ describe("WorkbaseService", () => {
|
|
|
41
41
|
).toBe(false)
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test("treats declared but missing repositories as valid aliases", async () => {
|
|
45
|
+
await write(
|
|
46
|
+
root,
|
|
47
|
+
"agency.json",
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
version: 2,
|
|
50
|
+
repositories: {
|
|
51
|
+
agency: { remote: "https://example.com/agency.git" },
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
await write(
|
|
56
|
+
root,
|
|
57
|
+
"tasks/portable/TASK.md",
|
|
58
|
+
`---
|
|
59
|
+
ticketUrl: null
|
|
60
|
+
repo: agency
|
|
61
|
+
branch: task/portable
|
|
62
|
+
base: main
|
|
63
|
+
pr: null
|
|
64
|
+
---
|
|
65
|
+
`,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const report = await runTestEffect(
|
|
69
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
expect(report.valid).toBe(true)
|
|
73
|
+
expect(report.issues).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
44
76
|
test("registers canonical workbase paths without duplicates", async () => {
|
|
45
77
|
const workbaseRoot = join(root, "workbase")
|
|
46
78
|
const nested = join(workbaseRoot, "nested")
|
|
@@ -323,6 +323,27 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
323
323
|
return { root, config: decoded.value }
|
|
324
324
|
}),
|
|
325
325
|
|
|
326
|
+
repositoryAliases: (startPath: string = process.cwd()) =>
|
|
327
|
+
Effect.gen(function* () {
|
|
328
|
+
const service = yield* WorkbaseService
|
|
329
|
+
const fs = yield* FileSystemService
|
|
330
|
+
const { root, config } = yield* service.loadConfig(startPath)
|
|
331
|
+
const aliases = new Set(Object.keys(config.repositories ?? {}))
|
|
332
|
+
const reposPath = join(root, "repos")
|
|
333
|
+
if (yield* fs.isDirectory(reposPath)) {
|
|
334
|
+
for (const entry of yield* fs.readDirectory(reposPath)) {
|
|
335
|
+
if (!entry.name.startsWith(".agency-")) aliases.add(entry.name)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return [...aliases].sort()
|
|
339
|
+
}),
|
|
340
|
+
|
|
341
|
+
hasRepositoryAlias: (alias: string, startPath: string = process.cwd()) =>
|
|
342
|
+
WorkbaseService.pipe(
|
|
343
|
+
Effect.flatMap((service) => service.repositoryAliases(startPath)),
|
|
344
|
+
Effect.map((aliases) => aliases.includes(alias)),
|
|
345
|
+
),
|
|
346
|
+
|
|
326
347
|
register: (startPath: string, configDirectory?: string, name?: string) =>
|
|
327
348
|
Effect.gen(function* () {
|
|
328
349
|
const service = yield* WorkbaseService
|
|
@@ -584,20 +605,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
584
605
|
.sort()
|
|
585
606
|
})
|
|
586
607
|
|
|
587
|
-
const
|
|
588
|
-
const reposPath = join(root, "repos")
|
|
589
|
-
if (!(yield* fs.isDirectory(reposPath))) {
|
|
590
|
-
return new Set<string>()
|
|
591
|
-
}
|
|
592
|
-
const entries = yield* fs.readDirectory(reposPath)
|
|
593
|
-
return new Set(
|
|
594
|
-
entries
|
|
595
|
-
.filter((entry) => entry.isDirectory || entry.isSymlink)
|
|
596
|
-
.map((entry) => entry.name),
|
|
597
|
-
)
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
const aliases = yield* readAliases
|
|
608
|
+
const aliases = new Set(yield* service.repositoryAliases(root))
|
|
601
609
|
|
|
602
610
|
const readDocument = <S extends Schema.Schema.AnyNoContext>(
|
|
603
611
|
path: string,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
7
|
+
|
|
8
|
+
const createTempDir = () => mkdtemp(join(tmpdir(), "agency-lock-test-"))
|
|
9
|
+
const cleanupTempDir = (path: string) =>
|
|
10
|
+
rm(path, { recursive: true, force: true })
|
|
11
|
+
|
|
12
|
+
describe("withWorktreeLocks", () => {
|
|
13
|
+
const tempDirs: string[] = []
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await Promise.all(tempDirs.splice(0).map(cleanupTempDir))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("rejects a concurrent operation for the same target", async () => {
|
|
20
|
+
const root = await createTempDir()
|
|
21
|
+
tempDirs.push(root)
|
|
22
|
+
let entered!: () => void
|
|
23
|
+
let release!: () => void
|
|
24
|
+
const enteredPromise = new Promise<void>((resolve) => {
|
|
25
|
+
entered = resolve
|
|
26
|
+
})
|
|
27
|
+
const releasePromise = new Promise<void>((resolve) => {
|
|
28
|
+
release = resolve
|
|
29
|
+
})
|
|
30
|
+
const held = Effect.runPromise(
|
|
31
|
+
withWorktreeLocks(
|
|
32
|
+
root,
|
|
33
|
+
[{ taskId: "alpha" }],
|
|
34
|
+
Effect.promise(async () => {
|
|
35
|
+
entered()
|
|
36
|
+
await releasePromise
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
await enteredPromise
|
|
41
|
+
|
|
42
|
+
const conflict = await Effect.runPromise(
|
|
43
|
+
Effect.either(
|
|
44
|
+
withWorktreeLocks(root, [{ taskId: "alpha" }], Effect.void),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
release()
|
|
48
|
+
await held
|
|
49
|
+
expect(conflict).toMatchObject({
|
|
50
|
+
_tag: "Left",
|
|
51
|
+
left: {
|
|
52
|
+
_tag: "WorktreeLockError",
|
|
53
|
+
message: "Another worktree operation is in progress for 'alpha'",
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("releases locks when the protected operation fails", async () => {
|
|
59
|
+
const root = await createTempDir()
|
|
60
|
+
tempDirs.push(root)
|
|
61
|
+
const failure = new Error("operation failed")
|
|
62
|
+
|
|
63
|
+
const result = await Effect.runPromise(
|
|
64
|
+
Effect.either(
|
|
65
|
+
withWorktreeLocks(
|
|
66
|
+
root,
|
|
67
|
+
[{ taskId: "alpha", phaseId: "build" }],
|
|
68
|
+
Effect.fail(failure),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
expect(result._tag).toBe("Left")
|
|
73
|
+
if (result._tag === "Left") expect(result.left).toBe(failure)
|
|
74
|
+
await expect(
|
|
75
|
+
Effect.runPromise(
|
|
76
|
+
withWorktreeLocks(
|
|
77
|
+
root,
|
|
78
|
+
[{ taskId: "alpha", phaseId: "build" }],
|
|
79
|
+
Effect.succeed("reacquired"),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
).resolves.toBe("reacquired")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("deduplicates targets while keeping task and phase locks distinct", async () => {
|
|
86
|
+
const root = await createTempDir()
|
|
87
|
+
tempDirs.push(root)
|
|
88
|
+
let entered!: () => void
|
|
89
|
+
let release!: () => void
|
|
90
|
+
const enteredPromise = new Promise<void>((resolve) => {
|
|
91
|
+
entered = resolve
|
|
92
|
+
})
|
|
93
|
+
const releasePromise = new Promise<void>((resolve) => {
|
|
94
|
+
release = resolve
|
|
95
|
+
})
|
|
96
|
+
const held = Effect.runPromise(
|
|
97
|
+
withWorktreeLocks(
|
|
98
|
+
root,
|
|
99
|
+
[
|
|
100
|
+
{ taskId: "alpha" },
|
|
101
|
+
{ taskId: "alpha" },
|
|
102
|
+
{ taskId: "alpha", phaseId: "build" },
|
|
103
|
+
],
|
|
104
|
+
Effect.promise(async () => {
|
|
105
|
+
entered()
|
|
106
|
+
await releasePromise
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
await enteredPromise
|
|
111
|
+
|
|
112
|
+
expect(
|
|
113
|
+
(await readdir(root)).filter((path) => path.endsWith(".lock")),
|
|
114
|
+
).toHaveLength(2)
|
|
115
|
+
|
|
116
|
+
release()
|
|
117
|
+
await held
|
|
118
|
+
expect(
|
|
119
|
+
(await readdir(root)).filter((path) => path.endsWith(".lock")),
|
|
120
|
+
).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -26,8 +26,16 @@ const git = async (args: string[], cwd?: string) => {
|
|
|
26
26
|
describe("WorktreeService", () => {
|
|
27
27
|
let root: string
|
|
28
28
|
let source: string
|
|
29
|
+
let effectRepositoryInitialized: boolean
|
|
30
|
+
|
|
31
|
+
const ensureEffectRepository = async () => {
|
|
32
|
+
if (effectRepositoryInitialized) return
|
|
33
|
+
await git(["clone", "--bare", source, join(root, "repos/effect")])
|
|
34
|
+
effectRepositoryInitialized = true
|
|
35
|
+
}
|
|
29
36
|
|
|
30
37
|
beforeEach(async () => {
|
|
38
|
+
effectRepositoryInitialized = false
|
|
31
39
|
root = await createTempDir()
|
|
32
40
|
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
33
41
|
source = join(root, "source")
|
|
@@ -40,12 +48,12 @@ describe("WorktreeService", () => {
|
|
|
40
48
|
await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
|
|
41
49
|
await mkdir(join(root, "repos"), { recursive: true })
|
|
42
50
|
await git(["clone", "--bare", source, join(root, "repos/agency")])
|
|
43
|
-
await git(["clone", "--bare", source, join(root, "repos/effect")])
|
|
44
51
|
})
|
|
45
52
|
|
|
46
53
|
afterEach(async () => cleanupTempDir(root))
|
|
47
54
|
|
|
48
55
|
test("materializes writable and reference worktrees", async () => {
|
|
56
|
+
await ensureEffectRepository()
|
|
49
57
|
await runTestEffect(
|
|
50
58
|
TaskService.pipe(
|
|
51
59
|
Effect.flatMap((service) =>
|
|
@@ -127,6 +135,7 @@ describe("WorktreeService", () => {
|
|
|
127
135
|
})
|
|
128
136
|
|
|
129
137
|
test("reuses an immutable reference checkout without fetching", async () => {
|
|
138
|
+
await ensureEffectRepository()
|
|
130
139
|
const commit = new TextDecoder()
|
|
131
140
|
.decode(
|
|
132
141
|
Bun.spawnSync(
|
|
@@ -393,7 +402,7 @@ describe("WorktreeService", () => {
|
|
|
393
402
|
{
|
|
394
403
|
id: "single",
|
|
395
404
|
ticketUrl: "https://example.com/task",
|
|
396
|
-
repo: "
|
|
405
|
+
repo: "agency",
|
|
397
406
|
branch: "task/single",
|
|
398
407
|
base: "main",
|
|
399
408
|
},
|
|
@@ -760,6 +769,7 @@ pr: null
|
|
|
760
769
|
})
|
|
761
770
|
|
|
762
771
|
test("fetches a moving ref before checking a reused reference checkout", async () => {
|
|
772
|
+
await ensureEffectRepository()
|
|
763
773
|
await runTestEffect(
|
|
764
774
|
TaskService.pipe(
|
|
765
775
|
Effect.flatMap((service) =>
|
|
@@ -801,6 +811,7 @@ pr: null
|
|
|
801
811
|
})
|
|
802
812
|
|
|
803
813
|
test("rejects a reference checkout attached to a branch", async () => {
|
|
814
|
+
await ensureEffectRepository()
|
|
804
815
|
await runTestEffect(
|
|
805
816
|
TaskService.pipe(
|
|
806
817
|
Effect.flatMap((service) =>
|
|
@@ -858,6 +869,7 @@ pr: null
|
|
|
858
869
|
})
|
|
859
870
|
|
|
860
871
|
test("removes worktrees without deleting branches", async () => {
|
|
872
|
+
await ensureEffectRepository()
|
|
861
873
|
await runTestEffect(
|
|
862
874
|
TaskService.pipe(
|
|
863
875
|
Effect.flatMap((service) =>
|
|
@@ -924,6 +936,7 @@ pr: null
|
|
|
924
936
|
})
|
|
925
937
|
|
|
926
938
|
test("reports dry-run fetch and worktree changes without mutating", async () => {
|
|
939
|
+
await ensureEffectRepository()
|
|
927
940
|
await runTestEffect(
|
|
928
941
|
TaskService.pipe(
|
|
929
942
|
Effect.flatMap((service) =>
|
|
@@ -983,6 +996,7 @@ pr: null
|
|
|
983
996
|
})
|
|
984
997
|
|
|
985
998
|
test("dry-run resolves a reference that exists only on the remote", async () => {
|
|
999
|
+
await ensureEffectRepository()
|
|
986
1000
|
await git(["checkout", "-b", "remote-only"], source)
|
|
987
1001
|
await Bun.write(join(source, "remote.txt"), "remote\n")
|
|
988
1002
|
await git(["add", "remote.txt"], source)
|
|
@@ -1316,6 +1330,7 @@ pr: null
|
|
|
1316
1330
|
})
|
|
1317
1331
|
|
|
1318
1332
|
test("dry-runs and rebuilds every clean declared checkout", async () => {
|
|
1333
|
+
await ensureEffectRepository()
|
|
1319
1334
|
await runTestEffect(
|
|
1320
1335
|
TaskService.pipe(
|
|
1321
1336
|
Effect.flatMap((service) =>
|
|
@@ -277,7 +277,7 @@ const inspectExecution = (
|
|
|
277
277
|
if (!(yield* fs.exists(repositoryPath))) {
|
|
278
278
|
conflict(
|
|
279
279
|
"missing-repository",
|
|
280
|
-
`Repository alias '${checkout.repo}'
|
|
280
|
+
`Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
|
|
281
281
|
)
|
|
282
282
|
checkouts.push({
|
|
283
283
|
repo: checkout.repo,
|
|
@@ -654,7 +654,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
654
654
|
const checkoutPath = join(codePath, alias)
|
|
655
655
|
if (!(yield* fs.exists(repositoryPath))) {
|
|
656
656
|
return yield* new WorktreeError({
|
|
657
|
-
message: `Repository alias '${alias}'
|
|
657
|
+
message: `Repository alias '${alias}' is not materialized; run 'agency repo setup --apply'`,
|
|
658
658
|
})
|
|
659
659
|
}
|
|
660
660
|
const listed = yield* fs.runCommand(
|
|
@@ -861,7 +861,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
861
861
|
const checkoutPath = join(codePath, alias)
|
|
862
862
|
if (!(yield* fs.exists(repositoryPath))) {
|
|
863
863
|
return yield* new WorktreeError({
|
|
864
|
-
message: `Repository alias '${alias}'
|
|
864
|
+
message: `Repository alias '${alias}' is not materialized; run 'agency repo setup --apply'`,
|
|
865
865
|
})
|
|
866
866
|
}
|
|
867
867
|
|
|
@@ -42,9 +42,10 @@ describe("spawnProcess", () => {
|
|
|
42
42
|
|
|
43
43
|
test("captures large stdout and stderr without hanging", async () => {
|
|
44
44
|
const line = "x".repeat(4096)
|
|
45
|
+
const lineCount = 256
|
|
45
46
|
const script = [
|
|
46
47
|
`const line = ${JSON.stringify(line)}`,
|
|
47
|
-
|
|
48
|
+
`for (let i = 0; i < ${lineCount}; i++) {`,
|
|
48
49
|
"console.log(`out:${i}:${line}`)",
|
|
49
50
|
"console.error(`err:${i}:${line}`)",
|
|
50
51
|
"}",
|
|
@@ -56,8 +57,8 @@ describe("spawnProcess", () => {
|
|
|
56
57
|
|
|
57
58
|
expect(result.exitCode).toBe(0)
|
|
58
59
|
expect(result.stdout).toContain("out:0:")
|
|
59
|
-
expect(result.stdout).toContain(
|
|
60
|
+
expect(result.stdout).toContain(`out:${lineCount - 1}:`)
|
|
60
61
|
expect(result.stderr).toContain("err:0:")
|
|
61
|
-
expect(result.stderr).toContain(
|
|
62
|
+
expect(result.stderr).toContain(`err:${lineCount - 1}:`)
|
|
62
63
|
})
|
|
63
64
|
})
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -16,6 +16,11 @@ Use the returned target, document paths and revisions, dependency readiness,
|
|
|
16
16
|
authority, checkout state, PR state, and validation result. Do not infer these
|
|
17
17
|
from directory names or stale prose.
|
|
18
18
|
|
|
19
|
+
If context or doctor reports a declared but missing repository, run
|
|
20
|
+
`agency repo setup --dry-run` and obtain explicit approval before
|
|
21
|
+
`agency repo setup --apply`. Missing declared aliases are setup state, not a
|
|
22
|
+
reason to edit `agency.json` or `repos/` by hand.
|
|
23
|
+
|
|
19
24
|
## Authority
|
|
20
25
|
|
|
21
26
|
- An epic or multi-phase task is orchestration context and has no implementation
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { findDependencyCycles, validateDependencies } from "./dependency-graph"
|
|
3
|
+
|
|
4
|
+
describe("dependency graph", () => {
|
|
5
|
+
test("accepts empty and acyclic dependency graphs", () => {
|
|
6
|
+
expect(validateDependencies([], "Tasks")).toBeUndefined()
|
|
7
|
+
expect(
|
|
8
|
+
validateDependencies(
|
|
9
|
+
[
|
|
10
|
+
{ id: "build" },
|
|
11
|
+
{ id: "test", dependsOn: ["build"] },
|
|
12
|
+
{ id: "ship", dependsOn: ["build", "test"] },
|
|
13
|
+
],
|
|
14
|
+
"Tasks",
|
|
15
|
+
),
|
|
16
|
+
).toBeUndefined()
|
|
17
|
+
expect(
|
|
18
|
+
findDependencyCycles([
|
|
19
|
+
{ id: "build" },
|
|
20
|
+
{ id: "test", dependsOn: ["build"] },
|
|
21
|
+
]),
|
|
22
|
+
).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("reports duplicate, self, and unknown dependencies precisely", () => {
|
|
26
|
+
expect(validateDependencies([{ id: "one" }, { id: "one" }], "Tasks")).toBe(
|
|
27
|
+
"Tasks IDs must be unique",
|
|
28
|
+
)
|
|
29
|
+
expect(
|
|
30
|
+
validateDependencies([{ id: "one", dependsOn: ["one"] }], "Tasks"),
|
|
31
|
+
).toBe("Task 'one' cannot depend on itself")
|
|
32
|
+
expect(
|
|
33
|
+
validateDependencies([{ id: "one", dependsOn: ["missing"] }], "Phases"),
|
|
34
|
+
).toBe("Unknown phase dependency 'missing'")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("detects cycles deterministically across disconnected graphs", () => {
|
|
38
|
+
const nodes = [
|
|
39
|
+
{ id: "delta", dependsOn: ["charlie"] },
|
|
40
|
+
{ id: "bravo", dependsOn: ["alpha"] },
|
|
41
|
+
{ id: "charlie", dependsOn: ["delta"] },
|
|
42
|
+
{ id: "alpha", dependsOn: ["bravo"] },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
expect(findDependencyCycles(nodes)).toEqual(["bravo", "delta"])
|
|
46
|
+
expect(validateDependencies(nodes, "Tasks")).toBe(
|
|
47
|
+
"Task dependency cycle includes 'bravo'",
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -9,8 +9,60 @@ import {
|
|
|
9
9
|
WorkStatus,
|
|
10
10
|
WorkbaseConfig,
|
|
11
11
|
WorkbaseRegistry,
|
|
12
|
+
RepositoryRemote,
|
|
12
13
|
} from "./schemas"
|
|
13
14
|
|
|
15
|
+
describe("portable repository declarations", () => {
|
|
16
|
+
test("accepts provider-neutral network Git remotes", () => {
|
|
17
|
+
for (const remote of [
|
|
18
|
+
"https://example.com/team/repository.git",
|
|
19
|
+
"ssh://git@example.com/team/repository.git",
|
|
20
|
+
"git://example.com/team/repository.git",
|
|
21
|
+
"git@example.com:team/repository.git",
|
|
22
|
+
"ssh://[2001:db8::1]/team/repository.git",
|
|
23
|
+
]) {
|
|
24
|
+
expect(Schema.decodeUnknownSync(RepositoryRemote)(remote)).toBe(remote)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("rejects local paths, unsupported protocols, and credential-bearing URLs", () => {
|
|
29
|
+
for (const remote of [
|
|
30
|
+
"/Users/person/repository.git",
|
|
31
|
+
"../repository.git",
|
|
32
|
+
"file:///tmp/repository.git",
|
|
33
|
+
"https://token@example.com/team/repository.git",
|
|
34
|
+
"https://user:password@example.com/team/repository.git",
|
|
35
|
+
"ssh://user:password@example.com/team/repository.git",
|
|
36
|
+
"ftp://example.com/team/repository.git",
|
|
37
|
+
"custom://example.com/team/repository.git",
|
|
38
|
+
"C:/repository.git",
|
|
39
|
+
"https://example.com/repository.git?token=secret",
|
|
40
|
+
"ext::printf",
|
|
41
|
+
"foo::bar",
|
|
42
|
+
"-host:repository.git",
|
|
43
|
+
"-oProxyCommand=x@host:repository.git",
|
|
44
|
+
]) {
|
|
45
|
+
expect(() => Schema.decodeUnknownSync(RepositoryRemote)(remote)).toThrow()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("decodes repository declarations in version 2 configs", () => {
|
|
50
|
+
expect(
|
|
51
|
+
Schema.decodeUnknownSync(WorkbaseConfig)({
|
|
52
|
+
version: 2,
|
|
53
|
+
repositories: {
|
|
54
|
+
agency: { remote: "https://example.com/agency.git" },
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
).toEqual({
|
|
58
|
+
version: 2,
|
|
59
|
+
repositories: {
|
|
60
|
+
agency: { remote: "https://example.com/agency.git" },
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
14
66
|
describe("body-of-work descriptions", () => {
|
|
15
67
|
test("accepts descriptions on epics, tasks, and phases", () => {
|
|
16
68
|
const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -13,6 +13,19 @@ export const EntityId = NonEmptyString.pipe(Schema.pattern(IdPattern))
|
|
|
13
13
|
|
|
14
14
|
export const RepositoryAlias = NonEmptyString.pipe(Schema.pattern(IdPattern))
|
|
15
15
|
|
|
16
|
+
// Portable declarations must be usable after cloning the workbase elsewhere.
|
|
17
|
+
// Local paths, file URLs, and credential-bearing HTTP URLs are intentionally
|
|
18
|
+
// excluded; SSH usernames are identities and remain supported.
|
|
19
|
+
export const RepositoryRemote = NonEmptyString.pipe(
|
|
20
|
+
Schema.pattern(
|
|
21
|
+
/^(?!-)(?![a-zA-Z]:[\\/])(?!https?:\/\/[^/@\s]+@)(?![a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/@\s]*:[^/@\s]*@)(?:(?:https?|ssh|git):\/\/[^\s?#]+|(?![^\s]*::)(?:[^@\s/:]+@)?[a-zA-Z0-9_.][^@\s/:]*:(?!\/\/)[^\s?#]+)$/,
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const RepositoryDeclaration = Schema.Struct({
|
|
26
|
+
remote: RepositoryRemote,
|
|
27
|
+
})
|
|
28
|
+
|
|
16
29
|
export const RepositoryReference = Schema.Struct({
|
|
17
30
|
repo: RepositoryAlias,
|
|
18
31
|
ref: NonEmptyString,
|
|
@@ -73,6 +86,9 @@ const DeliveryProvider = Schema.Struct({
|
|
|
73
86
|
|
|
74
87
|
export const WorkbaseConfig = Schema.Struct({
|
|
75
88
|
version: Schema.Literal(2),
|
|
89
|
+
repositories: Schema.optional(
|
|
90
|
+
Schema.Record({ key: RepositoryAlias, value: RepositoryDeclaration }),
|
|
91
|
+
),
|
|
76
92
|
chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
77
93
|
worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
78
94
|
runners: Schema.optional(
|
|
@@ -160,6 +176,9 @@ export type WorkbaseRegistration = Schema.Schema.Type<
|
|
|
160
176
|
>
|
|
161
177
|
export type Dependency = Schema.Schema.Type<typeof Dependency>
|
|
162
178
|
export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
|
|
179
|
+
export type RepositoryDeclaration = Schema.Schema.Type<
|
|
180
|
+
typeof RepositoryDeclaration
|
|
181
|
+
>
|
|
163
182
|
export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
|
|
164
183
|
export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
|
|
165
184
|
export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>
|