@markjaquith/agency 2.24.0 → 2.26.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.
@@ -2,6 +2,7 @@ import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { join, resolve } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
+ import { GraphService } from "./GraphService"
5
6
  import { WorkbaseService } from "./WorkbaseService"
6
7
  import { RepositoryAlias } from "../workbase/schemas"
7
8
 
@@ -18,6 +19,11 @@ interface RepositoryInfo {
18
19
  readonly target: string | null
19
20
  }
20
21
 
22
+ interface RepositoryVerification extends RepositoryInfo {
23
+ readonly valid: boolean
24
+ readonly issues: readonly string[]
25
+ }
26
+
21
27
  const validateAlias = (alias: string) => {
22
28
  const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
23
29
  return Either.isLeft(result)
@@ -29,6 +35,65 @@ const validateAlias = (alias: string) => {
29
35
  : Effect.succeed(result.right)
30
36
  }
31
37
 
38
+ const find = (alias: string, startPath: string) =>
39
+ Effect.gen(function* () {
40
+ const service = yield* RepositoryService
41
+ const validAlias = yield* validateAlias(alias)
42
+ const repositories = yield* service.list(startPath)
43
+ const repository = repositories.find((item) => item.alias === validAlias)
44
+ if (!repository) {
45
+ return yield* new RepositoryError({
46
+ message: `Unknown repository alias '${validAlias}'`,
47
+ })
48
+ }
49
+ return repository
50
+ })
51
+
52
+ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
53
+ Effect.gen(function* () {
54
+ const fs = yield* FileSystemService
55
+ const graph = yield* GraphService
56
+ const report = yield* graph.get({ cwd: startPath })
57
+ const repositoryId = `repository:${repository.alias}`
58
+ const references = report.edges
59
+ .filter(
60
+ (edge) =>
61
+ edge.to === repositoryId &&
62
+ (edge.kind === "writes" || edge.kind === "references"),
63
+ )
64
+ .map((edge) => edge.from)
65
+ .sort()
66
+ const worktrees: string[] = []
67
+ if (repository.kind !== "symlink") {
68
+ const result = yield* fs.runCommand(
69
+ ["git", "-C", repository.path, "worktree", "list", "--porcelain"],
70
+ { captureOutput: true },
71
+ )
72
+ if (result.exitCode === 0) {
73
+ for (const block of result.stdout.trim().split(/\n\n+/)) {
74
+ if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
75
+ const path = block.match(/^worktree (.+)$/m)?.[1]
76
+ if (path) worktrees.push(path)
77
+ }
78
+ }
79
+ }
80
+ return { references, worktrees }
81
+ })
82
+
83
+ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
84
+ Effect.gen(function* () {
85
+ const blockers = yield* removalBlockers(repository, startPath)
86
+ const details = [
87
+ ...blockers.references.map((item) => `active reference ${item}`),
88
+ ...blockers.worktrees.map((item) => `linked worktree ${item}`),
89
+ ]
90
+ if (details.length > 0) {
91
+ return yield* new RepositoryError({
92
+ message: `Repository alias '${repository.alias}' is in use and cannot be removed or renamed:\n${details.map((item) => `- ${item}`).join("\n")}`,
93
+ })
94
+ }
95
+ })
96
+
32
97
  export class RepositoryService extends Effect.Service<RepositoryService>()(
33
98
  "RepositoryService",
34
99
  {
@@ -154,6 +219,122 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
154
219
 
155
220
  return repositories
156
221
  }),
222
+
223
+ show: (alias: string, startPath: string = process.cwd()) =>
224
+ find(alias, startPath),
225
+
226
+ fetch: (alias: string, startPath: string = process.cwd()) =>
227
+ Effect.gen(function* () {
228
+ const fs = yield* FileSystemService
229
+ const repository = yield* find(alias, startPath)
230
+ const result = yield* fs.runCommand(
231
+ ["git", "-C", repository.path, "fetch", "--prune"],
232
+ { captureOutput: true },
233
+ )
234
+ if (result.exitCode !== 0) {
235
+ return yield* new RepositoryError({
236
+ message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
237
+ })
238
+ }
239
+ return repository
240
+ }),
241
+
242
+ remove: (alias: string, startPath: string = process.cwd()) =>
243
+ Effect.gen(function* () {
244
+ const fs = yield* FileSystemService
245
+ const repository = yield* find(alias, startPath)
246
+ yield* assertRemovable(repository, startPath)
247
+ yield* fs.deleteDirectory(repository.path)
248
+ return repository
249
+ }),
250
+
251
+ unlink: (alias: string, startPath: string = process.cwd()) =>
252
+ Effect.gen(function* () {
253
+ const service = yield* RepositoryService
254
+ const repository = yield* find(alias, startPath)
255
+ if (repository.kind !== "symlink") {
256
+ return yield* new RepositoryError({
257
+ message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
258
+ })
259
+ }
260
+ return yield* service.remove(alias, startPath)
261
+ }),
262
+
263
+ rename: (
264
+ alias: string,
265
+ newAlias: string,
266
+ startPath: string = process.cwd(),
267
+ ) =>
268
+ Effect.gen(function* () {
269
+ const fs = yield* FileSystemService
270
+ const workbase = yield* WorkbaseService
271
+ const repository = yield* find(alias, startPath)
272
+ const validNewAlias = yield* validateAlias(newAlias)
273
+ const root = yield* workbase.discover(startPath)
274
+ const destination = join(root, "repos", validNewAlias)
275
+ if (yield* fs.exists(destination)) {
276
+ return yield* new RepositoryError({
277
+ message: `Repository alias '${validNewAlias}' already exists`,
278
+ })
279
+ }
280
+ yield* assertRemovable(repository, startPath)
281
+ yield* fs.moveDirectory(repository.path, destination)
282
+ return yield* find(validNewAlias, startPath)
283
+ }),
284
+
285
+ remote: (
286
+ alias: string,
287
+ remote: string | undefined,
288
+ startPath: string = process.cwd(),
289
+ ) =>
290
+ Effect.gen(function* () {
291
+ const fs = yield* FileSystemService
292
+ const repository = yield* find(alias, startPath)
293
+ if (remote === undefined) return repository
294
+ if (!remote.trim()) {
295
+ return yield* new RepositoryError({
296
+ message: "Repository remote is required",
297
+ })
298
+ }
299
+ const hasOrigin = repository.remote !== null
300
+ const result = yield* fs.runCommand(
301
+ [
302
+ "git",
303
+ "-C",
304
+ repository.path,
305
+ "remote",
306
+ hasOrigin ? "set-url" : "add",
307
+ "origin",
308
+ remote,
309
+ ],
310
+ { captureOutput: true },
311
+ )
312
+ if (result.exitCode !== 0) {
313
+ return yield* new RepositoryError({
314
+ message: `Failed to update remote for repository '${alias}': ${result.stderr.trim()}`,
315
+ })
316
+ }
317
+ return yield* find(alias, startPath)
318
+ }),
319
+
320
+ verify: (alias: string, startPath: string = process.cwd()) =>
321
+ Effect.gen(function* () {
322
+ const fs = yield* FileSystemService
323
+ const repository = yield* find(alias, startPath)
324
+ const issues: string[] = []
325
+ const git = yield* fs.runCommand(
326
+ ["git", "-C", repository.path, "rev-parse", "--git-dir"],
327
+ { captureOutput: true },
328
+ )
329
+ if (git.exitCode !== 0) issues.push("Path is not a Git repository")
330
+ if (repository.remote === null)
331
+ issues.push("Origin remote is not configured")
332
+ return {
333
+ ...repository,
334
+ valid: issues.length === 0,
335
+ issues,
336
+ } satisfies RepositoryVerification
337
+ }),
157
338
  }),
158
339
  },
159
340
  ) {}
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { EpicService } from "./EpicService"
@@ -60,6 +60,52 @@ describe("task and phase services", () => {
60
60
  expect(epic.data.tasks).toEqual([{ id: "task-one" }])
61
61
  })
62
62
 
63
+ test("does not create a task when its parent update cannot start", async () => {
64
+ await runTestEffect(
65
+ EpicService.pipe(
66
+ Effect.flatMap((service) =>
67
+ service.create(
68
+ "locked",
69
+ "https://example.com/epic",
70
+ [{ repo: "agency", ref: "main" }],
71
+ root,
72
+ ),
73
+ ),
74
+ ),
75
+ )
76
+ const lock = join(root, ".agency-graph-mutation.lock")
77
+ await Bun.write(lock, "held")
78
+ await expect(
79
+ runTestEffect(
80
+ TaskService.pipe(
81
+ Effect.flatMap((service) =>
82
+ service.create(
83
+ {
84
+ id: "not-created",
85
+ ticketUrl: null,
86
+ epic: "locked",
87
+ repo: "agency",
88
+ branch: "task/not-created",
89
+ base: "main",
90
+ },
91
+ root,
92
+ ),
93
+ ),
94
+ ),
95
+ ),
96
+ ).rejects.toThrow("Another graph mutation is in progress")
97
+ await rm(lock)
98
+ expect(
99
+ await Bun.file(join(root, "tasks/not-created/TASK.md")).exists(),
100
+ ).toBe(false)
101
+ const epic = await runTestEffect(
102
+ EpicService.pipe(
103
+ Effect.flatMap((service) => service.show("locked", root)),
104
+ ),
105
+ )
106
+ expect(epic.data.tasks).toEqual([])
107
+ })
108
+
63
109
  test("creates and sequences phases on a multi-phase task", async () => {
64
110
  await runTestEffect(
65
111
  TaskService.pipe(
@@ -18,6 +18,10 @@ import {
18
18
  import { canTransitionStatus } from "../readiness"
19
19
  import { documentRevision } from "../workbase/document-revision"
20
20
  import { archivedTaskDirectory } from "../workbase/archive"
21
+ import {
22
+ documentWriteStep,
23
+ runLifecycleTransaction,
24
+ } from "./LifecycleTransaction"
21
25
 
22
26
  class TaskError extends Data.TaggedError("TaskError")<{
23
27
  readonly message: string
@@ -129,6 +133,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
129
133
  ...(data.repos ?? []).map((reference) => reference.repo),
130
134
  ]
131
135
  : []
136
+ if (new Set(referencedRepos).size !== referencedRepos.length) {
137
+ return yield* new TaskError({
138
+ message:
139
+ "Repository references must be unique and cannot include the writable repository",
140
+ })
141
+ }
132
142
  for (const alias of referencedRepos) {
133
143
  if (!(yield* fs.exists(join(root, "repos", alias)))) {
134
144
  return yield* new TaskError({
@@ -140,6 +150,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
140
150
  let parentEpic: EpicRecord | undefined
141
151
  if (input.epic) {
142
152
  parentEpic = yield* epics.show(input.epic, root)
153
+ if (parentEpic.data.tasks.some((task) => task.id === id)) {
154
+ return yield* new TaskError({
155
+ message: `Epic '${input.epic}' already lists task '${id}'`,
156
+ })
157
+ }
143
158
  }
144
159
 
145
160
  const title = id
@@ -150,9 +165,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
150
165
  data,
151
166
  `# ${title}\n\nDescribe the task outcome.`,
152
167
  )
153
- yield* fs.createDirectory(directory)
154
- yield* fs.writeFile(path, content)
155
-
168
+ const writes: {
169
+ path: string
170
+ content: string
171
+ create?: boolean
172
+ }[] = [{ path, content, create: true }]
156
173
  if (input.epic && parentEpic) {
157
174
  const parsed = yield* parseFrontmatter(
158
175
  parentEpic.content,
@@ -163,8 +180,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
163
180
  tasks: [...parentEpic.data.tasks, { id }],
164
181
  }
165
182
  const updated = formatMarkdownDocument(epicData, parsed.body)
166
- yield* fs.writeFile(parentEpic.path, updated)
183
+ writes.push({ path: parentEpic.path, content: updated })
167
184
  }
185
+ yield* runLifecycleTransaction({
186
+ root,
187
+ preconditions: parentEpic
188
+ ? [{ path: parentEpic.path, revision: parentEpic.revision }]
189
+ : [],
190
+ steps: [documentWriteStep(root, writes)],
191
+ })
168
192
 
169
193
  return {
170
194
  id,
@@ -111,6 +111,52 @@ describe("WorkbaseService", () => {
111
111
  expect(result.defaultWorkbase).toEqual(registered)
112
112
  })
113
113
 
114
+ test("shows, names, clears names, and defaults by path", async () => {
115
+ const workbaseRoot = join(root, "workbase")
116
+ const configDirectory = join(root, "config")
117
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
118
+ const registered = await runTestEffect(
119
+ WorkbaseService.pipe(
120
+ Effect.flatMap((service) =>
121
+ service.register(workbaseRoot, configDirectory),
122
+ ),
123
+ ),
124
+ )
125
+
126
+ const result = await runTestEffect(
127
+ WorkbaseService.pipe(
128
+ Effect.flatMap((service) =>
129
+ Effect.gen(function* () {
130
+ const named = yield* service.nameRegistered(
131
+ workbaseRoot,
132
+ "primary",
133
+ configDirectory,
134
+ )
135
+ const shown = yield* service.showRegistered(
136
+ "primary",
137
+ configDirectory,
138
+ )
139
+ const defaultWorkbase = yield* service.setDefault(
140
+ workbaseRoot,
141
+ configDirectory,
142
+ )
143
+ const unnamed = yield* service.nameRegistered(
144
+ registered.id,
145
+ null,
146
+ configDirectory,
147
+ )
148
+ return { named, shown, defaultWorkbase, unnamed }
149
+ }),
150
+ ),
151
+ ),
152
+ )
153
+
154
+ expect(result.named.name).toBe("primary")
155
+ expect(result.shown).toEqual(result.named)
156
+ expect(result.defaultWorkbase?.id).toBe(registered.id)
157
+ expect(result.unnamed).toEqual({ id: registered.id, path: registered.path })
158
+ })
159
+
114
160
  test("rejects names that collide with stable IDs", async () => {
115
161
  const firstRoot = join(root, "first")
116
162
  const secondRoot = join(root, "second")
@@ -146,6 +146,31 @@ const writeRegistry = (
146
146
  yield* fs.writeJSON(path, registry)
147
147
  })
148
148
 
149
+ const findRegistration = (
150
+ selector: string,
151
+ configDirectory?: string,
152
+ basePath: string = process.cwd(),
153
+ ) =>
154
+ Effect.gen(function* () {
155
+ const fs = yield* FileSystemService
156
+ const { path, registry } = yield* readRegistry(configDirectory)
157
+ const candidatePath = resolve(basePath, selector)
158
+ const canonicalCandidate = (yield* fs.exists(candidatePath))
159
+ ? yield* fs.realPath(candidatePath)
160
+ : candidatePath
161
+ const entry =
162
+ registry.workbases.find((item) => item.id === selector) ??
163
+ registry.workbases.find((item) => item.name === selector) ??
164
+ registry.workbases.find((item) => item.path === canonicalCandidate)
165
+ if (!entry) {
166
+ return yield* new WorkbaseRegistryError({
167
+ path,
168
+ message: `Unknown workbase selector '${selector}'`,
169
+ })
170
+ }
171
+ return { path, registry, entry }
172
+ })
173
+
149
174
  export class WorkbaseService extends Effect.Service<WorkbaseService>()(
150
175
  "WorkbaseService",
151
176
  {
@@ -401,22 +426,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
401
426
  basePath: string = process.cwd(),
402
427
  ) =>
403
428
  Effect.gen(function* () {
404
- const fs = yield* FileSystemService
405
- const { path, registry } = yield* readRegistry(configDirectory)
406
- const candidatePath = resolve(basePath, selector)
407
- const canonicalCandidate = (yield* fs.exists(candidatePath))
408
- ? yield* fs.realPath(candidatePath)
409
- : candidatePath
410
- const entry =
411
- registry.workbases.find((item) => item.id === selector) ??
412
- registry.workbases.find((item) => item.name === selector) ??
413
- registry.workbases.find((item) => item.path === canonicalCandidate)
414
- if (!entry) {
415
- return yield* new WorkbaseRegistryError({
416
- path,
417
- message: `Unknown workbase selector '${selector}'`,
418
- })
419
- }
429
+ const { path, registry, entry } = yield* findRegistration(
430
+ selector,
431
+ configDirectory,
432
+ basePath,
433
+ )
420
434
  const workbases = registry.workbases.filter(
421
435
  (item) => item.id !== entry.id,
422
436
  )
@@ -431,6 +445,61 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
431
445
  return entry
432
446
  }),
433
447
 
448
+ showRegistered: (
449
+ selector: string,
450
+ configDirectory?: string,
451
+ basePath: string = process.cwd(),
452
+ ) =>
453
+ findRegistration(selector, configDirectory, basePath).pipe(
454
+ Effect.map(({ entry }) => entry),
455
+ ),
456
+
457
+ nameRegistered: (
458
+ selector: string,
459
+ name: string | null,
460
+ configDirectory?: string,
461
+ basePath: string = process.cwd(),
462
+ ) =>
463
+ Effect.gen(function* () {
464
+ const { path, registry, entry } = yield* findRegistration(
465
+ selector,
466
+ configDirectory,
467
+ basePath,
468
+ )
469
+ if (name !== null) {
470
+ const decodedName = decode(EntityId, name)
471
+ if (!decodedName.success) {
472
+ return yield* new WorkbaseRegistryError({
473
+ path,
474
+ message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
475
+ })
476
+ }
477
+ const collision = registry.workbases.find(
478
+ (item) =>
479
+ item.id !== entry.id &&
480
+ (item.id === name || item.name === name),
481
+ )
482
+ if (collision) {
483
+ return yield* new WorkbaseRegistryError({
484
+ path,
485
+ message: `Workbase name '${name}' is already registered for ${collision.path}`,
486
+ })
487
+ }
488
+ }
489
+ const updated = {
490
+ id: entry.id,
491
+ ...(name === null ? {} : { name }),
492
+ path: entry.path,
493
+ }
494
+ yield* writeRegistry(path, {
495
+ ...registry,
496
+ workbases: registry.workbases.map((item) =>
497
+ item.id === entry.id ? updated : item,
498
+ ),
499
+ })
500
+ return updated
501
+ }),
502
+
434
503
  pruneRegistered: (configDirectory?: string) =>
435
504
  Effect.gen(function* () {
436
505
  const fs = yield* FileSystemService
@@ -455,7 +524,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
455
524
  return removed
456
525
  }),
457
526
 
458
- setDefault: (selector: string | null, configDirectory?: string) =>
527
+ setDefault: (
528
+ selector: string | null,
529
+ configDirectory?: string,
530
+ basePath: string = process.cwd(),
531
+ ) =>
459
532
  Effect.gen(function* () {
460
533
  const { path, registry } = yield* readRegistry(configDirectory)
461
534
  if (selector === null) {
@@ -465,15 +538,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
465
538
  })
466
539
  return null
467
540
  }
468
- const entry = registry.workbases.find(
469
- (item) => item.id === selector || item.name === selector,
541
+ const { entry } = yield* findRegistration(
542
+ selector,
543
+ configDirectory,
544
+ basePath,
470
545
  )
471
- if (!entry) {
472
- return yield* new WorkbaseRegistryError({
473
- path,
474
- message: `Unknown registered workbase selector '${selector}'`,
475
- })
476
- }
477
546
  yield* writeRegistry(path, {
478
547
  version: 2,
479
548
  workbases: registry.workbases,
@@ -0,0 +1,60 @@
1
+ import { Data, Effect } from "effect"
2
+ import { open, rm } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+
5
+ class WorktreeLockError extends Data.TaggedError("WorktreeLockError")<{
6
+ readonly message: string
7
+ readonly cause?: unknown
8
+ }> {}
9
+
10
+ export interface WorktreeLockTarget {
11
+ readonly taskId: string
12
+ readonly phaseId?: string
13
+ }
14
+
15
+ const withWorktreeLock = <A, E, R>(
16
+ root: string,
17
+ target: WorktreeLockTarget,
18
+ effect: Effect.Effect<A, E, R>,
19
+ ): Effect.Effect<A, E | WorktreeLockError, R> => {
20
+ const key = Buffer.from(
21
+ `${target.taskId}:${target.phaseId ?? "task"}`,
22
+ ).toString("hex")
23
+ const lockPath = join(root, `.agency-worktree-${key}.lock`)
24
+ return Effect.acquireUseRelease(
25
+ Effect.tryPromise({
26
+ try: () => open(lockPath, "wx"),
27
+ catch: (cause) =>
28
+ new WorktreeLockError({
29
+ message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'`,
30
+ cause,
31
+ }),
32
+ }),
33
+ () => effect,
34
+ (lock) =>
35
+ Effect.promise(async () => {
36
+ await lock.close().catch(() => undefined)
37
+ await rm(lockPath, { force: true }).catch(() => undefined)
38
+ }),
39
+ )
40
+ }
41
+
42
+ export const withWorktreeLocks = <A, E, R>(
43
+ root: string,
44
+ targets: readonly WorktreeLockTarget[],
45
+ effect: Effect.Effect<A, E, R>,
46
+ ): Effect.Effect<A, E | WorktreeLockError, R> => {
47
+ const unique = new Map(
48
+ targets.map((target) => [
49
+ `${target.taskId}:${target.phaseId ?? "task"}`,
50
+ target,
51
+ ]),
52
+ )
53
+ let current: Effect.Effect<A, E | WorktreeLockError, R> = effect
54
+ for (const [, target] of [...unique.entries()]
55
+ .sort(([left], [right]) => left.localeCompare(right))
56
+ .reverse()) {
57
+ current = withWorktreeLock(root, target, current)
58
+ }
59
+ return current
60
+ }